Compare commits
16 Commits
add-produc
...
7eae4a0b29
| Author | SHA1 | Date | |
|---|---|---|---|
| 7eae4a0b29 | |||
| f421154c1d | |||
| 03dc119a15 | |||
| 1963bee00c | |||
| 675a0fc374 | |||
| ca2653ea1a | |||
| a8d3fd8033 | |||
| 702b956ff1 | |||
| 9b8577f258 | |||
| 9623681a15 | |||
| cc22fd8c35 | |||
| 0ef1b6100e | |||
| a519746ccb | |||
| f29dd8ef8b | |||
| f2a5c06005 | |||
| fb9f959fe5 |
172
docs/PERMISSIONS.md
Normal file
172
docs/PERMISSIONS.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# Permission System Documentation
|
||||
|
||||
This document outlines the permission system implemented in the Inventory Manager application.
|
||||
|
||||
## Permission Structure
|
||||
|
||||
Permissions follow this naming convention:
|
||||
|
||||
- Page access: `access:{page_name}`
|
||||
- Actions: `{action}:{resource}`
|
||||
|
||||
Examples:
|
||||
- `access:products` - Can access the Products page
|
||||
- `create:products` - Can create new products
|
||||
- `edit:users` - Can edit user accounts
|
||||
|
||||
## Permission Components
|
||||
|
||||
### PermissionGuard
|
||||
|
||||
The core component that conditionally renders content based on permissions.
|
||||
|
||||
```tsx
|
||||
<PermissionGuard
|
||||
permission="create:products"
|
||||
fallback={<p>No permission</p>}
|
||||
>
|
||||
<button>Create Product</button>
|
||||
</PermissionGuard>
|
||||
```
|
||||
|
||||
Options:
|
||||
- `permission`: Single permission code
|
||||
- `anyPermissions`: Array of permissions (ANY match grants access)
|
||||
- `allPermissions`: Array of permissions (ALL required)
|
||||
- `adminOnly`: For admin-only sections
|
||||
- `page`: Page name (checks `access:{page}` permission)
|
||||
- `fallback`: Content to show if permission check fails
|
||||
|
||||
### PermissionProtectedRoute
|
||||
|
||||
Protects entire pages based on page access permissions.
|
||||
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<PermissionProtectedRoute page="products">
|
||||
<Products />
|
||||
</PermissionProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
### ProtectedSection
|
||||
|
||||
Protects sections within a page based on action permissions.
|
||||
|
||||
```tsx
|
||||
<ProtectedSection page="products" action="create">
|
||||
<button>Add Product</button>
|
||||
</ProtectedSection>
|
||||
```
|
||||
|
||||
### PermissionButton
|
||||
|
||||
Button that automatically handles permissions.
|
||||
|
||||
```tsx
|
||||
<PermissionButton
|
||||
page="products"
|
||||
action="create"
|
||||
onClick={handleCreateProduct}
|
||||
>
|
||||
Add Product
|
||||
</PermissionButton>
|
||||
```
|
||||
|
||||
### SettingsSection
|
||||
|
||||
Specific component for settings with built-in permission checks.
|
||||
|
||||
```tsx
|
||||
<SettingsSection
|
||||
title="System Settings"
|
||||
description="Configure global settings"
|
||||
permission="edit:system_settings"
|
||||
>
|
||||
{/* Settings content */}
|
||||
</SettingsSection>
|
||||
```
|
||||
|
||||
## Permission Hooks
|
||||
|
||||
### usePermissions
|
||||
|
||||
Core hook for checking any permission.
|
||||
|
||||
```tsx
|
||||
const { hasPermission, hasPageAccess, isAdmin } = usePermissions();
|
||||
if (hasPermission('delete:products')) {
|
||||
// Can delete products
|
||||
}
|
||||
```
|
||||
|
||||
### usePagePermission
|
||||
|
||||
Specialized hook for page-level permissions.
|
||||
|
||||
```tsx
|
||||
const { canView, canCreate, canEdit, canDelete } = usePagePermission('products');
|
||||
if (canEdit()) {
|
||||
// Can edit products
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
Permissions are stored in the database:
|
||||
- `permissions` table: Stores all available permissions
|
||||
- `user_permissions` junction table: Maps permissions to users
|
||||
|
||||
Admin users automatically have all permissions.
|
||||
|
||||
## Common Permission Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `access:dashboard` | Access to Dashboard page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `create:products` | Create new products |
|
||||
| `edit:products` | Edit existing products |
|
||||
| `delete:products` | Delete products |
|
||||
| `view:users` | View user accounts |
|
||||
| `edit:users` | Edit user accounts |
|
||||
| `manage:permissions` | Assign permissions to users |
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### Page Protection
|
||||
|
||||
In `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<PermissionProtectedRoute page="products">
|
||||
<Products />
|
||||
</PermissionProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
### Component Level Protection
|
||||
|
||||
```tsx
|
||||
const { canEdit } = usePagePermission('products');
|
||||
|
||||
function handleEdit() {
|
||||
if (!canEdit()) {
|
||||
toast.error("You don't have permission");
|
||||
return;
|
||||
}
|
||||
// Edit logic
|
||||
}
|
||||
```
|
||||
|
||||
### UI Element Protection
|
||||
|
||||
```tsx
|
||||
<PermissionButton
|
||||
page="products"
|
||||
action="delete"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</PermissionButton>
|
||||
```
|
||||
128
inventory-server/auth/permissions.js
Normal file
128
inventory-server/auth/permissions.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// Get pool from global or create a new one if not available
|
||||
let pool;
|
||||
if (typeof global.pool !== 'undefined') {
|
||||
pool = global.pool;
|
||||
} else {
|
||||
// If global pool is not available, create a new connection
|
||||
const { Pool } = require('pg');
|
||||
pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
console.log('Created new database pool in permissions.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
513
inventory-server/auth/routes.js
Normal file
513
inventory-server/auth/routes.js
Normal file
@@ -0,0 +1,513 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { requirePermission, getUserPermissions } = require('./permissions');
|
||||
|
||||
// Get pool from global or create a new one if not available
|
||||
let pool;
|
||||
if (typeof global.pool !== 'undefined') {
|
||||
pool = global.pool;
|
||||
} else {
|
||||
// If global pool is not available, create a new connection
|
||||
const { Pool } = require('pg');
|
||||
pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT,
|
||||
});
|
||||
console.log('Created new database pool in routes.js');
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
console.log("Create user request:", {
|
||||
username,
|
||||
email,
|
||||
is_admin,
|
||||
is_active,
|
||||
permissions: permissions || []
|
||||
});
|
||||
|
||||
// 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) {
|
||||
console.log("Adding permissions for new user:", userId);
|
||||
console.log("Permissions received:", permissions);
|
||||
|
||||
// Check permission format
|
||||
const permissionIds = permissions.map(p => {
|
||||
if (typeof p === 'object' && p.id) {
|
||||
console.log("Permission is an object with ID:", p.id);
|
||||
return parseInt(p.id, 10);
|
||||
} else if (typeof p === 'number') {
|
||||
console.log("Permission is a number:", p);
|
||||
return p;
|
||||
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
||||
console.log("Permission is a string that can be parsed as a number:", p);
|
||||
return parseInt(p, 10);
|
||||
} else {
|
||||
console.log("Unknown permission format:", typeof p, p);
|
||||
// If it's a permission code, we need to look up the ID
|
||||
return null;
|
||||
}
|
||||
}).filter(id => id !== null);
|
||||
|
||||
console.log("Filtered permission IDs:", permissionIds);
|
||||
|
||||
if (permissionIds.length > 0) {
|
||||
const permissionValues = permissionIds
|
||||
.map(permId => `(${userId}, ${permId})`)
|
||||
.join(',');
|
||||
|
||||
console.log("Inserting permission values:", permissionValues);
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
INSERT INTO user_permissions (user_id, permission_id)
|
||||
VALUES ${permissionValues}
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
console.log("Successfully inserted permissions for new user:", userId);
|
||||
} catch (err) {
|
||||
console.error("Error inserting permissions for new user:", err);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
console.log("No valid permission IDs found to insert for new user");
|
||||
}
|
||||
} else {
|
||||
console.log("Not adding permissions: is_admin =", is_admin, "permissions array:", Array.isArray(permissions), "length:", permissions ? permissions.length : 0);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
console.log("Update user request:", {
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
is_admin,
|
||||
is_active,
|
||||
permissions: permissions || []
|
||||
});
|
||||
|
||||
// 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)) {
|
||||
console.log("Updating permissions for user:", userId);
|
||||
console.log("Permissions received:", permissions);
|
||||
|
||||
// First remove existing permissions
|
||||
await client.query(
|
||||
'DELETE FROM user_permissions WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
console.log("Deleted existing permissions for user:", 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;
|
||||
|
||||
console.log("User is admin:", newIsAdmin);
|
||||
|
||||
if (!newIsAdmin && permissions.length > 0) {
|
||||
console.log("Adding permissions:", permissions);
|
||||
|
||||
// Check permission format
|
||||
const permissionIds = permissions.map(p => {
|
||||
if (typeof p === 'object' && p.id) {
|
||||
console.log("Permission is an object with ID:", p.id);
|
||||
return parseInt(p.id, 10);
|
||||
} else if (typeof p === 'number') {
|
||||
console.log("Permission is a number:", p);
|
||||
return p;
|
||||
} else if (typeof p === 'string' && !isNaN(parseInt(p, 10))) {
|
||||
console.log("Permission is a string that can be parsed as a number:", p);
|
||||
return parseInt(p, 10);
|
||||
} else {
|
||||
console.log("Unknown permission format:", typeof p, p);
|
||||
// If it's a permission code, we need to look up the ID
|
||||
return null;
|
||||
}
|
||||
}).filter(id => id !== null);
|
||||
|
||||
console.log("Filtered permission IDs:", permissionIds);
|
||||
|
||||
if (permissionIds.length > 0) {
|
||||
const permissionValues = permissionIds
|
||||
.map(permId => `(${userId}, ${permId})`)
|
||||
.join(',');
|
||||
|
||||
console.log("Inserting permission values:", permissionValues);
|
||||
|
||||
try {
|
||||
await client.query(`
|
||||
INSERT INTO user_permissions (user_id, permission_id)
|
||||
VALUES ${permissionValues}
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
console.log("Successfully inserted permissions for user:", userId);
|
||||
} catch (err) {
|
||||
console.error("Error inserting permissions:", err);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
console.log("No valid permission IDs found to insert");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -4,3 +4,129 @@ CREATE TABLE users (
|
||||
password VARCHAR(255) NOT NULL,
|
||||
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;
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -53,6 +57,11 @@ app.post('/login', async (req, res) => {
|
||||
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(
|
||||
{ userId: user.id, username: user.username },
|
||||
@@ -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' });
|
||||
|
||||
14
inventory-server/package-lock.json
generated
14
inventory-server/package-lock.json
generated
@@ -1537,20 +1537,6 @@
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,47 +1,10 @@
|
||||
const { Pool, Client } = require('pg');
|
||||
const { Client: SSHClient } = require('ssh2');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
let pool;
|
||||
|
||||
function initPool(config) {
|
||||
// Log config without sensitive data
|
||||
const safeConfig = {
|
||||
host: config.host || process.env.DB_HOST,
|
||||
user: config.user || process.env.DB_USER,
|
||||
database: config.database || process.env.DB_NAME,
|
||||
port: config.port || process.env.DB_PORT || 5432,
|
||||
max: config.max || 10,
|
||||
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
||||
ssl: config.ssl || false,
|
||||
password: (config.password || process.env.DB_PASSWORD) ? '[password set]' : '[no password]'
|
||||
};
|
||||
console.log('[Database] Initializing pool with config:', safeConfig);
|
||||
|
||||
// Create the pool with the configuration
|
||||
pool = new Pool({
|
||||
host: config.host || process.env.DB_HOST,
|
||||
user: config.user || process.env.DB_USER,
|
||||
password: config.password || process.env.DB_PASSWORD,
|
||||
database: config.database || process.env.DB_NAME,
|
||||
port: config.port || process.env.DB_PORT || 5432,
|
||||
max: config.max || 10,
|
||||
idleTimeoutMillis: config.idleTimeoutMillis || 30000,
|
||||
connectionTimeoutMillis: config.connectionTimeoutMillis || 2000,
|
||||
ssl: config.ssl || false
|
||||
});
|
||||
|
||||
// Test the pool connection
|
||||
return pool.connect()
|
||||
.then(client => {
|
||||
console.log('[Database] Pool connection successful');
|
||||
client.release();
|
||||
return pool;
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Database] Connection failed:', err);
|
||||
throw err;
|
||||
});
|
||||
pool = mysql.createPool(config);
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function getConnection() {
|
||||
|
||||
30
inventory/package-lock.json
generated
30
inventory/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Routes, Route, useNavigate, Navigate } from 'react-router-dom';
|
||||
import { Routes, Route, useNavigate, Navigate, useLocation } from 'react-router-dom';
|
||||
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';
|
||||
@@ -17,63 +16,122 @@ import { Vendors } from '@/pages/Vendors';
|
||||
import { Categories } from '@/pages/Categories';
|
||||
import { Import } from '@/pages/Import';
|
||||
import { AiValidationDebug } from "@/pages/AiValidationDebug"
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { Protected } from './components/auth/Protected';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = sessionStorage.getItem('token');
|
||||
if (token) {
|
||||
const token = localStorage.getItem('token');
|
||||
const isLoggedIn = sessionStorage.getItem('isLoggedIn') === 'true';
|
||||
|
||||
// If we have a token but aren't logged in yet, verify the token
|
||||
if (token && !isLoggedIn) {
|
||||
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');
|
||||
|
||||
// Only navigate to login if we're not already there
|
||||
if (!location.pathname.includes('/login')) {
|
||||
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||
}
|
||||
} else {
|
||||
// If token is valid, set the login flag
|
||||
sessionStorage.setItem('isLoggedIn', 'true');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token verification failed:', error);
|
||||
sessionStorage.removeItem('token');
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
navigate('/login');
|
||||
|
||||
// Only navigate to login if we're not already there
|
||||
if (!location.pathname.includes('/login')) {
|
||||
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [navigate]);
|
||||
}, [navigate, location.pathname, location.search]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster richColors position="top-center" />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/import" element={<Import />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/vendors" element={<Vendors />} />
|
||||
<Route path="/orders" element={<Orders />} />
|
||||
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/forecasting" element={<Forecasting />} />
|
||||
<Route path="/ai-validation/debug" element={<AiValidationDebug />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<AuthProvider>
|
||||
<Toaster richColors position="top-center" />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
<Route path="/" element={
|
||||
<Protected page="dashboard">
|
||||
<Dashboard />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/products" element={
|
||||
<Protected page="products">
|
||||
<Products />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/import" element={
|
||||
<Protected page="import">
|
||||
<Import />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/categories" element={
|
||||
<Protected page="categories">
|
||||
<Categories />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/vendors" element={
|
||||
<Protected page="vendors">
|
||||
<Vendors />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/purchase-orders" element={
|
||||
<Protected page="purchase_orders">
|
||||
<PurchaseOrders />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/analytics" element={
|
||||
<Protected page="analytics">
|
||||
<Analytics />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<Protected page="settings">
|
||||
<Settings />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/forecasting" element={
|
||||
<Protected page="forecasting">
|
||||
<Forecasting />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/ai-validation/debug" element={
|
||||
<Protected page="ai_validation_debug">
|
||||
<AiValidationDebug />
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
104
inventory/src/components/auth/PERMISSIONS.md
Normal file
104
inventory/src/components/auth/PERMISSIONS.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Permission System Documentation
|
||||
|
||||
This document outlines the simplified permission system implemented in the Inventory Manager application.
|
||||
|
||||
## Permission Structure
|
||||
|
||||
Permissions follow this naming convention:
|
||||
|
||||
- Page access: `access:{page_name}`
|
||||
- Actions: `{action}:{resource}`
|
||||
|
||||
Examples:
|
||||
- `access:products` - Can access the Products page
|
||||
- `create:products` - Can create new products
|
||||
- `edit:users` - Can edit user accounts
|
||||
|
||||
## Permission Component
|
||||
|
||||
### Protected
|
||||
|
||||
The core component that conditionally renders content based on permissions.
|
||||
|
||||
```tsx
|
||||
<Protected
|
||||
permission="create:products"
|
||||
fallback={<p>No permission</p>}
|
||||
>
|
||||
<button>Create Product</button>
|
||||
</Protected>
|
||||
```
|
||||
|
||||
Options:
|
||||
- `permission`: Single permission code (e.g., "create:products")
|
||||
- `page`: Page name (checks `access:{page}` permission)
|
||||
- `resource` + `action`: Resource and action (checks `{action}:{resource}` permission)
|
||||
- `adminOnly`: For admin-only sections
|
||||
- `fallback`: Content to show if permission check fails
|
||||
|
||||
### RequireAuth
|
||||
|
||||
Used for basic authentication checks (is user logged in?).
|
||||
|
||||
```tsx
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
</RequireAuth>
|
||||
}>
|
||||
{/* Protected routes */}
|
||||
</Route>
|
||||
```
|
||||
|
||||
## Common Permission Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `access:dashboard` | Access to Dashboard page |
|
||||
| `access:products` | Access to Products page |
|
||||
| `create:products` | Create new products |
|
||||
| `edit:products` | Edit existing products |
|
||||
| `delete:products` | Delete products |
|
||||
| `view:users` | View user accounts |
|
||||
| `edit:users` | Edit user accounts |
|
||||
| `manage:permissions` | Assign permissions to users |
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### Page Protection
|
||||
|
||||
In `App.tsx`:
|
||||
```tsx
|
||||
<Route path="/products" element={
|
||||
<Protected page="products" fallback={<Navigate to="/" />}>
|
||||
<Products />
|
||||
</Protected>
|
||||
} />
|
||||
```
|
||||
|
||||
### Component Level Protection
|
||||
|
||||
```tsx
|
||||
<Protected permission="edit:products">
|
||||
<form>
|
||||
{/* Form fields */}
|
||||
<button type="submit">Save Changes</button>
|
||||
</form>
|
||||
</Protected>
|
||||
```
|
||||
|
||||
### Button Protection
|
||||
|
||||
```tsx
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!hasPermission('delete:products')}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
// With Protected component
|
||||
<Protected permission="delete:products" fallback={null}>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
</Protected>
|
||||
```
|
||||
82
inventory/src/components/auth/Protected.tsx
Normal file
82
inventory/src/components/auth/Protected.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ReactNode, useContext } from "react";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
interface ProtectedProps {
|
||||
// For specific permission code
|
||||
permission?: string;
|
||||
|
||||
// For page access permission format: access:{page}
|
||||
page?: string;
|
||||
|
||||
// For action permission format: {action}:{resource}
|
||||
resource?: string;
|
||||
action?: "view" | "create" | "edit" | "delete" | string;
|
||||
|
||||
// For admin-only access
|
||||
adminOnly?: boolean;
|
||||
|
||||
// Content to render if permission check passes
|
||||
children: ReactNode;
|
||||
|
||||
// Optional fallback content
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A simplified component that conditionally renders content based on user permissions
|
||||
*/
|
||||
export function Protected({
|
||||
permission,
|
||||
page,
|
||||
resource,
|
||||
action,
|
||||
adminOnly,
|
||||
children,
|
||||
fallback = null
|
||||
}: ProtectedProps) {
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
// If user isn't loaded yet, don't render anything
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Admin check - admins always have access to everything
|
||||
if (user.is_admin) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Admin-only check
|
||||
if (adminOnly) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Check permissions array exists
|
||||
if (!user.permissions) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// Page access check (access:page)
|
||||
if (page) {
|
||||
const pagePermission = `access:${page.toLowerCase()}`;
|
||||
if (!user.permissions.includes(pagePermission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
}
|
||||
|
||||
// Resource action check (action:resource)
|
||||
if (resource && action) {
|
||||
const resourcePermission = `${action}:${resource.toLowerCase()}`;
|
||||
if (!user.permissions.includes(resourcePermission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
}
|
||||
|
||||
// Single permission check
|
||||
if (permission && !user.permissions.includes(permission)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
// If all checks pass, render children
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,8 +1,45 @@
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { useContext, useEffect, useState } from "react"
|
||||
import { AuthContext } from "@/contexts/AuthContext"
|
||||
|
||||
export function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const isLoggedIn = sessionStorage.getItem("isLoggedIn") === "true"
|
||||
const { token, user, fetchCurrentUser } = useContext(AuthContext)
|
||||
const location = useLocation()
|
||||
const [isLoading, setIsLoading] = useState(!!token && !user)
|
||||
|
||||
// This will make sure the user data is loaded the first time
|
||||
useEffect(() => {
|
||||
const loadUserData = async () => {
|
||||
if (token && !user) {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await fetchCurrentUser()
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadUserData()
|
||||
}, [token, user, fetchCurrentUser])
|
||||
|
||||
// Check if token exists but we're not logged in
|
||||
useEffect(() => {
|
||||
if (token && !isLoggedIn) {
|
||||
// Verify the token and fetch user data
|
||||
fetchCurrentUser().catch(() => {
|
||||
// Do nothing - the AuthContext will handle errors
|
||||
})
|
||||
}
|
||||
}, [token, isLoggedIn, fetchCurrentUser])
|
||||
|
||||
// If still loading user data, show nothing yet
|
||||
if (isLoading) {
|
||||
return <div className="p-8 flex justify-center items-center h-screen">Loading...</div>
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// Redirect to login with the current path in the redirect parameter
|
||||
|
||||
@@ -24,47 +24,56 @@ import {
|
||||
SidebarSeparator,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Overview",
|
||||
icon: Home,
|
||||
url: "/",
|
||||
permission: "access:dashboard"
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
icon: Package,
|
||||
url: "/products",
|
||||
permission: "access:products"
|
||||
},
|
||||
{
|
||||
title: "Import",
|
||||
icon: FileSpreadsheet,
|
||||
url: "/import",
|
||||
permission: "access:import"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
url: "/forecasting",
|
||||
permission: "access:forecasting"
|
||||
},
|
||||
{
|
||||
title: "Categories",
|
||||
icon: Tags,
|
||||
url: "/categories",
|
||||
permission: "access:categories"
|
||||
},
|
||||
{
|
||||
title: "Vendors",
|
||||
icon: Users,
|
||||
url: "/vendors",
|
||||
permission: "access:vendors"
|
||||
},
|
||||
{
|
||||
title: "Purchase Orders",
|
||||
icon: ClipboardList,
|
||||
url: "/purchase-orders",
|
||||
permission: "access:purchase_orders"
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: BarChart2,
|
||||
url: "/analytics",
|
||||
permission: "access:analytics"
|
||||
},
|
||||
];
|
||||
|
||||
@@ -73,8 +82,8 @@ export function AppSidebar() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
sessionStorage.removeItem('token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
@@ -98,20 +107,26 @@ export function AppSidebar() {
|
||||
location.pathname === item.url ||
|
||||
(item.url !== "/" && location.pathname.startsWith(item.url));
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<Protected
|
||||
key={item.title}
|
||||
permission={item.permission}
|
||||
fallback={null}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
{item.title}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
@@ -122,20 +137,25 @@ export function AppSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Settings"
|
||||
isActive={location.pathname === "/settings"}
|
||||
>
|
||||
<Link to="/settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
Settings
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<Protected
|
||||
permission="access:settings"
|
||||
fallback={null}
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip="Settings"
|
||||
isActive={location.pathname === "/settings"}
|
||||
>
|
||||
<Link to="/settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">
|
||||
Settings
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</Protected>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { motion } from "motion/react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
|
||||
130
inventory/src/components/settings/PermissionSelector.tsx
Normal file
130
inventory/src/components/settings/PermissionSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
338
inventory/src/components/settings/UserForm.tsx
Normal file
338
inventory/src/components/settings/UserForm.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
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";
|
||||
|
||||
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>;
|
||||
|
||||
// Helper function to get all permission IDs from all categories
|
||||
const getAllPermissionIds = (permissionCategories: PermissionCategory[]): number[] => {
|
||||
const allIds: number[] = [];
|
||||
|
||||
if (permissionCategories && permissionCategories.length > 0) {
|
||||
permissionCategories.forEach(category => {
|
||||
category.permissions.forEach(permission => {
|
||||
allIds.push(permission.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return allIds;
|
||||
};
|
||||
|
||||
// User save data interface (represents the data structure for saving users)
|
||||
interface UserSaveData {
|
||||
id?: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
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(() => {
|
||||
console.log("User permissions:", user?.permissions);
|
||||
|
||||
if (user?.permissions && Array.isArray(user.permissions) && user.permissions.length > 0) {
|
||||
// Extract IDs from the permissions
|
||||
const permissionIds = user.permissions.map(p => p.id);
|
||||
console.log("Setting selected permissions:", permissionIds);
|
||||
setSelectedPermissions(permissionIds);
|
||||
} else {
|
||||
console.log("No permissions found or empty permissions array");
|
||||
setSelectedPermissions([]);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = (data: FormValues) => {
|
||||
try {
|
||||
setFormError(null);
|
||||
console.log("Form submitted with permissions:", selectedPermissions);
|
||||
|
||||
// Validate
|
||||
if (!user && !data.password) {
|
||||
setFormError("Password is required for new users");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare the data
|
||||
const userData: UserSaveData = {
|
||||
...data,
|
||||
id: user?.id, // Include ID if editing existing user
|
||||
permissions: [] // Initialize with empty array
|
||||
};
|
||||
|
||||
// If editing and password is empty, remove it
|
||||
if (user && !userData.password) {
|
||||
delete userData.password;
|
||||
}
|
||||
|
||||
// Add permissions if not admin
|
||||
if (!data.is_admin) {
|
||||
// Find the actual permission objects from selectedPermissions IDs
|
||||
const selectedPermissionObjects: Permission[] = [];
|
||||
|
||||
if (permissions && permissions.length > 0) {
|
||||
// Loop through all available permissions to find the selected ones
|
||||
permissions.forEach(category => {
|
||||
category.permissions.forEach(permission => {
|
||||
// If this permission's ID is in the selectedPermissions array, add it
|
||||
if (selectedPermissions.includes(permission.id)) {
|
||||
selectedPermissionObjects.push(permission);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
userData.permissions = selectedPermissionObjects;
|
||||
} else {
|
||||
// For admin users, don't send permissions as they're implied
|
||||
userData.permissions = [];
|
||||
}
|
||||
|
||||
console.log("Saving user data:", userData);
|
||||
onSave(userData);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "An error occurred";
|
||||
setFormError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// For debugging
|
||||
console.log("Current form state:", form.getValues());
|
||||
console.log("Available permissions categories:", permissions);
|
||||
console.log("Selected permissions:", selectedPermissions);
|
||||
console.log("Is admin:", form.watch("is_admin"));
|
||||
|
||||
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 }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<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 }) => (
|
||||
<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 }) => (
|
||||
<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>
|
||||
|
||||
{permissions && permissions.length > 0 && (
|
||||
<>
|
||||
{form.watch("is_admin") ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Permissions</h3>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Administrators have access to all permissions by default. Individual permissions cannot be edited for admin users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<PermissionSelector
|
||||
permissionsByCategory={permissions}
|
||||
selectedPermissions={getAllPermissionIds(permissions)}
|
||||
onChange={() => {}}
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PermissionSelector
|
||||
permissionsByCategory={permissions}
|
||||
selectedPermissions={selectedPermissions}
|
||||
onChange={setSelectedPermissions}
|
||||
/>
|
||||
{selectedPermissions.length === 0 && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Warning: This user has no permissions selected. They won't be able to access anything.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
94
inventory/src/components/settings/UserList.tsx
Normal file
94
inventory/src/components/settings/UserList.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
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) {
|
||||
console.log("Rendering user list with users:", users);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
356
inventory/src/components/settings/UserManagement.tsx
Normal file
356
inventory/src/components/settings/UserManagement.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserList } from "./UserList";
|
||||
import { UserForm } from "./UserForm";
|
||||
import config from "@/config";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
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 { token, fetchCurrentUser, user } = useContext(AuthContext);
|
||||
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
|
||||
const fetchData = async () => {
|
||||
if (!token) {
|
||||
setError("Authentication required. Please log in again.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The PermissionGuard component already handles permission checks,
|
||||
// so we don't need to duplicate that logic here
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Fetch users
|
||||
const usersResponse = await fetch(`${config.authUrl}/users`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!usersResponse.ok) {
|
||||
if (usersResponse.status === 401) {
|
||||
throw new Error('Authentication failed. Please log in again.');
|
||||
} else if (usersResponse.status === 403) {
|
||||
throw new Error('You don\'t have permission to access the user list.');
|
||||
} else {
|
||||
// Try to get more detailed error message from response
|
||||
try {
|
||||
const errorData = await usersResponse.json();
|
||||
throw new Error(errorData.error || `Failed to fetch users (${usersResponse.status})`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to fetch users (${usersResponse.status})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usersData = await usersResponse.json();
|
||||
setUsers(usersData);
|
||||
|
||||
// Fetch permissions
|
||||
const permissionsResponse = await fetch(`${config.authUrl}/permissions/categories`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!permissionsResponse.ok) {
|
||||
if (permissionsResponse.status === 401) {
|
||||
throw new Error('Authentication failed. Please log in again.');
|
||||
} else if (permissionsResponse.status === 403) {
|
||||
throw new Error('You don\'t have permission to access permissions.');
|
||||
} else {
|
||||
// Try to get more detailed error message from response
|
||||
try {
|
||||
const errorData = await permissionsResponse.json();
|
||||
throw new Error(errorData.error || `Failed to fetch permissions (${permissionsResponse.status})`);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to fetch permissions (${permissionsResponse.status})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// If authentication error, refresh the token
|
||||
if (err instanceof Error && err.message.includes('Authentication failed')) {
|
||||
fetchCurrentUser().catch(() => {
|
||||
// Handle failed token refresh
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [token]);
|
||||
|
||||
const handleEditUser = async (userId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${config.authUrl}/users/${userId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication failed. Please log in again.');
|
||||
} else if (response.status === 403) {
|
||||
throw new Error('You don\'t have permission to edit users.');
|
||||
} else {
|
||||
throw new Error(`Failed to fetch user details (${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
console.log("Fetched user data for editing:", userData);
|
||||
|
||||
// Ensure permissions is always an array
|
||||
if (!userData.permissions) {
|
||||
userData.permissions = [];
|
||||
}
|
||||
|
||||
// Make sure the permissions are in the right format for the form
|
||||
// The server might send either an array of permission objects or just permission codes
|
||||
if (userData.permissions && userData.permissions.length > 0) {
|
||||
// Check if permissions are objects with id property
|
||||
if (typeof userData.permissions[0] === 'string') {
|
||||
// If we just have permission codes, we need to convert them to objects with ids
|
||||
// by looking them up in the permissions data
|
||||
const permissionObjects = [];
|
||||
|
||||
// Go through each permission category
|
||||
for (const category of permissions) {
|
||||
// For each permission in the category
|
||||
for (const permission of category.permissions) {
|
||||
// If this permission's code is in the user's permission codes
|
||||
if (userData.permissions.includes(permission.code)) {
|
||||
permissionObjects.push(permission);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userData.permissions = permissionObjects;
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
console.log("Saving user data:", userData);
|
||||
|
||||
// Format permissions for the API - convert from permission objects to IDs
|
||||
let formattedUserData = { ...userData };
|
||||
|
||||
if (userData.permissions && Array.isArray(userData.permissions)) {
|
||||
// Check if permissions are objects (from the form) and convert to IDs for the API
|
||||
if (userData.permissions.length > 0 && typeof userData.permissions[0] === 'object') {
|
||||
// The backend expects permission IDs, not just the code strings
|
||||
formattedUserData.permissions = userData.permissions.map(p => p.id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Formatted user data for API:", formattedUserData);
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Use PUT for updating, POST for creating
|
||||
const method = userData.id ? 'PUT' : 'POST';
|
||||
const endpoint = userData.id
|
||||
? `${config.authUrl}/users/${userData.id}`
|
||||
: `${config.authUrl}/users`;
|
||||
|
||||
console.log(`${method} request to ${endpoint}`);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(formattedUserData)
|
||||
});
|
||||
|
||||
let responseData;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch (e) {
|
||||
console.error("Error parsing response JSON:", e);
|
||||
throw new Error("Invalid response from server");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Error response from server:", responseData);
|
||||
throw new Error(responseData.error || responseData.message || `Failed to save user (${response.status})`);
|
||||
}
|
||||
|
||||
console.log("Server response after saving user:", responseData);
|
||||
|
||||
// Reset the form state
|
||||
setSelectedUser(null);
|
||||
setIsAddingUser(false);
|
||||
|
||||
// Refresh the user list
|
||||
fetchData();
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save user';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
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(`${config.authUrl}/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
|
||||
// Refresh user list after a successful delete
|
||||
await fetchData();
|
||||
} 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" className="mb-4">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
<AlertTitle>Permission Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={fetchData}>Retry</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
176
inventory/src/components/ui/form.tsx
Normal file
176
inventory/src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
158
inventory/src/contexts/AuthContext.tsx
Normal file
158
inventory/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { createContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import config from '@/config';
|
||||
|
||||
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);
|
||||
|
||||
const fetchCurrentUser = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`${config.authUrl}/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to fetch user data');
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
console.log("Fetched current user data:", userData);
|
||||
console.log("User permissions:", userData.permissions);
|
||||
|
||||
setUser(userData);
|
||||
// Ensure we have the sessionStorage isLoggedIn flag set
|
||||
sessionStorage.setItem('isLoggedIn', 'true');
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
setError(errorMessage);
|
||||
console.error('Auth error:', errorMessage);
|
||||
|
||||
// Clear token if authentication failed
|
||||
if (err instanceof Error &&
|
||||
(err.message.includes('authentication') ||
|
||||
err.message.includes('token') ||
|
||||
err.message.includes('401'))) {
|
||||
logout();
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
// Load token and fetch user data on init
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchCurrentUser();
|
||||
} else {
|
||||
// Clear sessionStorage if no token exists
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
}
|
||||
}, [token, fetchCurrentUser]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`${config.authUrl}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Login successful, received data:", data);
|
||||
console.log("User permissions:", data.user?.permissions);
|
||||
|
||||
localStorage.setItem('token', data.token);
|
||||
sessionStorage.setItem('isLoggedIn', 'true');
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Login failed';
|
||||
setError(errorMessage);
|
||||
console.error('Login error:', errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
sessionStorage.removeItem('isLoggedIn');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useContext } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import config from "../config";
|
||||
import { Loader2, Box } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
export function Login() {
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -15,59 +14,22 @@ export function Login() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useContext(AuthContext);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const url = `${config.authUrl}/login`;
|
||||
console.log("Making login request:", {
|
||||
url,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: { username, password },
|
||||
config,
|
||||
});
|
||||
await login(username, password);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
console.log("Login response status:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Failed to parse error response" }));
|
||||
console.error("Login failed:", data);
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Login successful:", data);
|
||||
|
||||
sessionStorage.setItem("token", data.token);
|
||||
sessionStorage.setItem("isLoggedIn", "true");
|
||||
toast.success("Successfully logged in");
|
||||
|
||||
// Get the redirect URL from the URL parameters, defaulting to "/"
|
||||
const redirectTo = searchParams.get("redirect") || "/"
|
||||
|
||||
// Navigate to the redirect URL after successful login
|
||||
navigate(redirectTo)
|
||||
// Login successful, redirect to the requested page or home
|
||||
const redirectTo = searchParams.get("redirect") || "/";
|
||||
navigate(redirectTo);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Login failed";
|
||||
toast.error(message);
|
||||
console.error("Login error:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "An unexpected error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import { StockManagement } from "@/components/settings/StockManagement";
|
||||
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
|
||||
import { CalculationSettings } from "@/components/settings/CalculationSettings";
|
||||
import { TemplateManagement } from "@/components/settings/TemplateManagement";
|
||||
import { motion } from 'motion/react';
|
||||
import { UserManagement } from "@/components/settings/UserManagement";
|
||||
import { motion } from 'framer-motion';
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Protected } from "@/components/auth/Protected";
|
||||
|
||||
export function Settings() {
|
||||
return (
|
||||
@@ -20,12 +23,22 @@ export function Settings() {
|
||||
<TabsTrigger value="performance-metrics">
|
||||
Performance Metrics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="calculation-settings">
|
||||
Calculation Settings
|
||||
</TabsTrigger>
|
||||
<Protected permission="edit:system_settings">
|
||||
<TabsTrigger value="calculation-settings">
|
||||
Calculation Settings
|
||||
</TabsTrigger>
|
||||
</Protected>
|
||||
<TabsTrigger value="templates">
|
||||
Template Management
|
||||
</TabsTrigger>
|
||||
<Protected
|
||||
permission="view:users"
|
||||
fallback={null}
|
||||
>
|
||||
<TabsTrigger value="user-management">
|
||||
User Management
|
||||
</TabsTrigger>
|
||||
</Protected>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="data-management">
|
||||
@@ -41,12 +54,38 @@ export function Settings() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="calculation-settings">
|
||||
<CalculationSettings />
|
||||
<Protected
|
||||
permission="edit:system_settings"
|
||||
fallback={
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to access Calculation Settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<CalculationSettings />
|
||||
</Protected>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<TemplateManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="user-management">
|
||||
<Protected
|
||||
permission="view:users"
|
||||
fallback={
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You don't have permission to access User Management.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
>
|
||||
<UserManagement />
|
||||
</Protected>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user