19 Commits

Author SHA1 Message Date
d60a8cbc6e Hide debug components without permission 2025-03-23 22:06:51 -04:00
1fcbf54989 Layout/style tweaks, fix performance metrics settings page 2025-03-23 22:01:41 -04:00
ce75496770 Clean up unused permissions, take user to first page/component they can access 2025-03-23 17:18:31 -04:00
7eae4a0b29 More permissions setup, simplify to one component 2025-03-23 16:04:32 -04:00
f421154c1d Get user management page working, add permission checking in more places 2025-03-22 22:27:50 -04:00
03dc119a15 Initial permissions framework and setup 2025-03-22 22:11:03 -04:00
1963bee00c Merge branch 'add-product-upload-page' 2025-03-22 21:11:10 -04:00
675a0fc374 Fix incorrect columns in import scripts 2025-02-18 10:46:16 -05:00
ca2653ea1a Update import scripts through POs 2025-02-17 22:17:01 -05:00
a8d3fd8033 Update import scripts through orders 2025-02-17 00:53:07 -05:00
702b956ff1 Fix main import script issue 2025-02-16 11:54:28 -05:00
9b8577f258 Update import scripts through products 2025-02-14 21:46:50 -05:00
9623681a15 Update import scripts, working through categories 2025-02-14 13:30:14 -05:00
cc22fd8c35 Update backend/frontend 2025-02-14 11:26:02 -05:00
0ef1b6100e Clean up old files 2025-02-14 09:37:05 -05:00
a519746ccb Move authentication to postgres 2025-02-14 09:10:15 -05:00
f29dd8ef8b Clean up build errors 2025-02-13 20:02:11 -05:00
f2a5c06005 Fixes for re-running reset scripts 2025-02-13 10:25:04 -05:00
fb9f959fe5 Update schemas and reset scripts 2025-02-12 16:14:25 -05:00
33 changed files with 3426 additions and 840 deletions

172
docs/PERMISSIONS.md Normal file
View 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>
```

View 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
};

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

View File

@@ -2,5 +2,88 @@ CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
email VARCHAR UNIQUE,
is_admin BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
last_login TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Sequence and defined type for users table if not exists
CREATE SEQUENCE IF NOT EXISTS users_id_seq;
-- 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 - only the ones used in application
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;
-- Settings section permissions
INSERT INTO permissions (name, code, description, category) VALUES
('Data Management', 'settings:data_management', 'Access to the Data Management settings section', 'Settings'),
('Stock Management', 'settings:stock_management', 'Access to the Stock Management settings section', 'Settings'),
('Performance Metrics', 'settings:performance_metrics', 'Access to the Performance Metrics settings section', 'Settings'),
('Calculation Settings', 'settings:calculation_settings', 'Access to the Calculation Settings section', 'Settings'),
('Template Management', 'settings:templates', 'Access to the Template Management settings section', 'Settings'),
('User Management', 'settings:user_management', 'Access to the User Management settings section', 'Settings')
ON CONFLICT (code) DO NOTHING;
-- Set any existing users as admin
UPDATE users SET is_admin = TRUE WHERE is_admin IS NULL;
-- Grant all permissions to admin users
INSERT INTO user_permissions (user_id, permission_id)
SELECT u.id, p.id
FROM users u, permissions p
WHERE u.is_admin = TRUE
ON CONFLICT DO NOTHING;

View File

@@ -5,6 +5,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { 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' });

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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();
pool = mysql.createPool(config);
return pool;
})
.catch(err => {
console.error('[Database] Connection failed:', err);
throw err;
});
}
async function getConnection() {

View File

@@ -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",

View File

@@ -15,6 +15,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",
@@ -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",

View File

@@ -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';
@@ -16,42 +15,61 @@ import Forecasting from "@/pages/Forecasting";
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';
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
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}>
<AuthProvider>
<Toaster richColors position="top-center" />
<Routes>
<Route path="/login" element={<Login />} />
@@ -60,20 +78,60 @@ function App() {
<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 index element={
<Protected page="dashboard" fallback={<FirstAccessiblePage />}>
<Dashboard />
</Protected>
} />
<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="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</AuthProvider>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,44 @@
import { useContext } from "react";
import { Navigate } from "react-router-dom";
import { AuthContext } from "@/contexts/AuthContext";
// Define available pages in order of priority
const PAGES = [
{ path: "/products", permission: "access:products" },
{ path: "/categories", permission: "access:categories" },
{ path: "/vendors", permission: "access:vendors" },
{ path: "/purchase-orders", permission: "access:purchase_orders" },
{ path: "/analytics", permission: "access:analytics" },
{ path: "/forecasting", permission: "access:forecasting" },
{ path: "/import", permission: "access:import" },
{ path: "/settings", permission: "access:settings" },
{ path: "/ai-validation/debug", permission: "access:ai_validation_debug" }
];
export function FirstAccessiblePage() {
const { user } = useContext(AuthContext);
// If user isn't loaded yet, don't render anything
if (!user) {
return null;
}
// Admin users have access to all pages, so this component
// shouldn't be rendering for them (handled by App.tsx)
if (user.is_admin) {
return null;
}
// Find the first page the user has access to
const firstAccessiblePage = PAGES.find(page => {
return user.permissions?.includes(page.permission);
});
// If we found a page, redirect to it
if (firstAccessiblePage) {
return <Navigate to={firstAccessiblePage.path} replace />;
}
// If user has no access to any page, redirect to login
return <Navigate to="/login" replace />;
}

View 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>
```

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

View File

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

View File

@@ -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,7 +107,12 @@ export function AppSidebar() {
location.pathname === item.url ||
(item.url !== "/" && location.pathname.startsWith(item.url));
return (
<SidebarMenuItem key={item.title}>
<Protected
key={item.title}
permission={item.permission}
fallback={null}
>
<SidebarMenuItem>
<SidebarMenuButton
asChild
tooltip={item.title}
@@ -112,6 +126,7 @@ export function AppSidebar() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</Protected>
);
})}
</SidebarMenu>
@@ -122,6 +137,10 @@ export function AppSidebar() {
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<Protected
permission="access:settings"
fallback={null}
>
<SidebarMenuItem>
<SidebarMenuButton
asChild
@@ -136,10 +155,12 @@ export function AppSidebar() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</Protected>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarSeparator />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>

View File

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

View File

@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { AiValidationDialogs } from '../../../components/AiValidationDialogs';
import { Product } from '../../../types/product';
import { config } from '../../../config';
interface CurrentPrompt {
isOpen: boolean;
prompt: string;
isLoading: boolean;
debugData?: {
taxonomyStats: {
categories: number;
themes: number;
colors: number;
taxCodes: number;
sizeCategories: number;
suppliers: number;
companies: number;
artists: number;
} | null;
basePrompt: string;
sampleFullPrompt: string;
promptLength: number;
estimatedProcessingTime?: {
seconds: number | null;
sampleCount: number;
};
};
}
const ValidationStepNew: React.FC = () => {
const [aiValidationProgress, setAiValidationProgress] = useState(0);
const [aiValidationDetails, setAiValidationDetails] = useState('');
const [currentPrompt, setCurrentPrompt] = useState<CurrentPrompt>({
isOpen: false,
prompt: '',
isLoading: true,
});
const [isChangeReverted, setIsChangeReverted] = useState(false);
const [fieldData, setFieldData] = useState<Product[]>([]);
const showCurrentPrompt = async (products: Product[]) => {
setCurrentPrompt((prev) => ({ ...prev, isOpen: true, isLoading: true }));
try {
// Get the prompt
const promptResponse = await fetch(`${config.apiUrl}/ai-validation/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ products })
});
if (!promptResponse.ok) {
throw new Error('Failed to fetch AI prompt');
}
const promptData = await promptResponse.json();
// Get the debug data in the same request or as a separate request
const debugResponse = await fetch(`${config.apiUrl}/ai-validation/debug-info`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: promptData.prompt })
});
let debugData;
if (debugResponse.ok) {
debugData = await debugResponse.json();
} else {
// If debug-info fails, use a fallback to get taxonomy stats
const fallbackResponse = await fetch(`${config.apiUrl}/ai-validation/debug`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ products: [products[0]] }) // Use first product for stats
});
if (fallbackResponse.ok) {
debugData = await fallbackResponse.json();
// Set promptLength correctly from the actual prompt
debugData.promptLength = promptData.prompt.length;
}
}
setCurrentPrompt((prev) => ({
...prev,
prompt: promptData.prompt,
isLoading: false,
debugData: debugData
}));
} catch (error) {
console.error('Error fetching prompt:', error);
setCurrentPrompt((prev) => ({
...prev,
prompt: 'Error loading prompt',
isLoading: false
}));
}
};
const revertAiChange = () => {
setIsChangeReverted(true);
};
const getFieldDisplayValueWithHighlight = (value: string, highlight: string) => {
// Implementation of getFieldDisplayValueWithHighlight
};
return (
<div>
<AiValidationDialogs
aiValidationProgress={aiValidationProgress}
aiValidationDetails={aiValidationDetails}
currentPrompt={currentPrompt}
setAiValidationProgress={setAiValidationProgress}
setAiValidationDetails={setAiValidationDetails}
setCurrentPrompt={setCurrentPrompt}
revertAiChange={revertAiChange}
isChangeReverted={isChangeReverted}
getFieldDisplayValueWithHighlight={getFieldDisplayValueWithHighlight}
fields={fieldData}
debugData={currentPrompt.debugData}
/>
</div>
);
};
export default ValidationStepNew;

View File

@@ -1,23 +1,72 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { Loader2, CheckIcon } from 'lucide-react';
import { Code } from '@/components/ui/code';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { AiValidationDetails, AiValidationProgress, CurrentPrompt } from '../hooks/useAiValidation';
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Loader2, CheckIcon } from "lucide-react";
import { Code } from "@/components/ui/code";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AiValidationDetails,
AiValidationProgress,
CurrentPrompt,
} from "../hooks/useAiValidation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface TaxonomyStats {
categories: number;
themes: number;
colors: number;
taxCodes: number;
sizeCategories: number;
suppliers: number;
companies: number;
artists: number;
}
interface DebugData {
taxonomyStats: TaxonomyStats | null;
basePrompt: string;
sampleFullPrompt: string;
promptLength: number;
estimatedProcessingTime?: {
seconds: number | null;
sampleCount: number;
};
}
interface AiValidationDialogsProps {
aiValidationProgress: AiValidationProgress;
aiValidationDetails: AiValidationDetails;
currentPrompt: CurrentPrompt;
setAiValidationProgress: React.Dispatch<React.SetStateAction<AiValidationProgress>>;
setAiValidationDetails: React.Dispatch<React.SetStateAction<AiValidationDetails>>;
setAiValidationProgress: React.Dispatch<
React.SetStateAction<AiValidationProgress>
>;
setAiValidationDetails: React.Dispatch<
React.SetStateAction<AiValidationDetails>
>;
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
revertAiChange: (productIndex: number, fieldKey: string) => void;
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
getFieldDisplayValueWithHighlight: (fieldKey: string, originalValue: any, correctedValue: any) => { originalHtml: string, correctedHtml: string };
getFieldDisplayValueWithHighlight: (
fieldKey: string,
originalValue: any,
correctedValue: any
) => { originalHtml: string; correctedHtml: string };
fields: readonly any[];
debugData?: DebugData;
}
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
@@ -30,31 +79,182 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
revertAiChange,
isChangeReverted,
getFieldDisplayValueWithHighlight,
fields
fields,
debugData,
}) => {
const [costPerMillionTokens, setCostPerMillionTokens] = useState(2.5); // Default cost
// Format time helper
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)} seconds`;
} else {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
};
// Calculate token costs
const calculateTokenCost = (promptLength: number): number => {
const estimatedTokens = Math.round(promptLength / 4);
return (estimatedTokens / 1_000_000) * costPerMillionTokens * 100; // In cents
};
// Use the prompt length from the current prompt
const promptLength = currentPrompt.prompt ? currentPrompt.prompt.length : 0;
return (
<>
{/* Current Prompt Dialog */}
{/* Current Prompt Dialog with Debug Info */}
<Dialog
open={currentPrompt.isOpen}
onOpenChange={(open) => setCurrentPrompt(prev => ({ ...prev, isOpen: open }))}
onOpenChange={(open) =>
setCurrentPrompt((prev) => ({ ...prev, isOpen: open }))
}
>
<DialogContent className="max-w-4xl h-[80vh]">
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Current AI Prompt</DialogTitle>
<DialogDescription>
This is the exact prompt that would be sent to the AI for validation
This is the current prompt that would be sent to the AI for
validation
</DialogDescription>
</DialogHeader>
<ScrollArea className="flex-1">
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
{/* Debug Information Section */}
<div className="mb-4 flex-shrink-0">
{currentPrompt.isLoading ? (
<div className="flex justify-center items-center h-[100px]"></div>
) : (
<div className="grid grid-cols-3 gap-4">
<Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">Prompt Length</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
<div className="text-sm">
<span className="text-muted-foreground">
Characters:
</span>{" "}
<span className="font-semibold">{promptLength}</span>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Tokens:</span>{" "}
<span className="font-semibold">
~{Math.round(promptLength / 4)}
</span>
</div>
</div>
</CardContent>
</Card>
<Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">Cost Estimate</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center">
<label
htmlFor="costPerMillion"
className="text-sm text-muted-foreground"
>
$
</label>
<input
id="costPerMillion"
className="w-[40px] px-1 border rounded-md text-sm"
defaultValue={costPerMillionTokens.toFixed(2)}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) {
setCostPerMillionTokens(value);
}
}}
/>
<label
htmlFor="costPerMillion"
className="text-sm text-muted-foreground ml-1"
>
per million input tokens
</label>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Cost:</span>{" "}
<span className="font-semibold">
{calculateTokenCost(promptLength).toFixed(1)}¢
</span>
</div>
</div>
</CardContent>
</Card>
<Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">
Processing Time
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
{debugData?.estimatedProcessingTime ? (
debugData.estimatedProcessingTime.seconds ? (
<>
<div className="text-sm">
<span className="text-muted-foreground">
Estimated time:
</span>{" "}
<span className="font-semibold">
{formatTime(
debugData.estimatedProcessingTime.seconds
)}
</span>
</div>
<div className="text-xs text-muted-foreground">
Based on{" "}
{debugData.estimatedProcessingTime.sampleCount}{" "}
similar validation
{debugData.estimatedProcessingTime
.sampleCount !== 1
? "s"
: ""}
</div>
</>
) : (
<div className="text-sm text-muted-foreground">
No historical data available for this prompt size
</div>
)
) : (
<div className="text-sm text-muted-foreground">
No processing time data available
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</div>
{/* Prompt Section */}
<div className="flex-1 min-h-0">
<ScrollArea className="h-full w-full">
{currentPrompt.isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<Code className="whitespace-pre-wrap p-4">{currentPrompt.prompt}</Code>
<Code className="whitespace-pre-wrap p-4 break-normal max-w-full">
{currentPrompt.prompt}
</Code>
)}
</ScrollArea>
</div>
</div>
</DialogContent>
</Dialog>
@@ -64,7 +264,7 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
onOpenChange={(open) => {
// Only allow closing if validation failed
if (!open && aiValidationProgress.step === -1) {
setAiValidationProgress(prev => ({ ...prev, isOpen: false }));
setAiValidationProgress((prev) => ({ ...prev, isOpen: false }));
}
}}
>
@@ -79,14 +279,25 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
<div
className="h-full bg-primary transition-all duration-500"
style={{
width: `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`,
backgroundColor: aiValidationProgress.step === -1 ? 'var(--destructive)' : undefined
width: `${
aiValidationProgress.progressPercent ??
Math.round((aiValidationProgress.step / 5) * 100)
}%`,
backgroundColor:
aiValidationProgress.step === -1
? "var(--destructive)"
: undefined,
}}
/>
</div>
</div>
<div className="text-sm text-muted-foreground w-12 text-right">
{aiValidationProgress.step === -1 ? '❌' : `${aiValidationProgress.progressPercent ?? Math.round((aiValidationProgress.step / 5) * 100)}%`}
{aiValidationProgress.step === -1
? "❌"
: `${
aiValidationProgress.progressPercent ??
Math.round((aiValidationProgress.step / 5) * 100)
}%`}
</div>
</div>
<p className="text-center text-sm text-muted-foreground">
@@ -94,20 +305,28 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
</p>
{(() => {
// Only show time remaining if we have an estimate and are in progress
return aiValidationProgress.estimatedSeconds &&
return (
aiValidationProgress.estimatedSeconds &&
aiValidationProgress.elapsedSeconds !== undefined &&
aiValidationProgress.step > 0 &&
aiValidationProgress.step < 5 && (
<div className="text-center text-sm">
{(() => {
// Calculate time remaining using the elapsed seconds
const elapsedSeconds = aiValidationProgress.elapsedSeconds;
const totalEstimatedSeconds = aiValidationProgress.estimatedSeconds;
const remainingSeconds = Math.max(0, totalEstimatedSeconds - elapsedSeconds);
const elapsedSeconds =
aiValidationProgress.elapsedSeconds;
const totalEstimatedSeconds =
aiValidationProgress.estimatedSeconds;
const remainingSeconds = Math.max(
0,
totalEstimatedSeconds - elapsedSeconds
);
// Format time remaining
if (remainingSeconds < 60) {
return `Approximately ${Math.round(remainingSeconds)} seconds remaining`;
return `Approximately ${Math.round(
remainingSeconds
)} seconds remaining`;
} else {
const minutes = Math.floor(remainingSeconds / 60);
const seconds = Math.round(remainingSeconds % 60);
@@ -116,10 +335,13 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
})()}
{aiValidationProgress.promptLength && (
<p className="mt-1 text-xs text-muted-foreground">
Prompt length: {aiValidationProgress.promptLength.toLocaleString()} characters
Prompt length:{" "}
{aiValidationProgress.promptLength.toLocaleString()}{" "}
characters
</p>
)}
</div>
)
);
})()}
</div>
@@ -129,7 +351,9 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
{/* AI Validation Results Dialog */}
<Dialog
open={aiValidationDetails.isOpen}
onOpenChange={(open) => setAiValidationDetails(prev => ({ ...prev, isOpen: open }))}
onOpenChange={(open) =>
setAiValidationDetails((prev) => ({ ...prev, isOpen: open }))
}
>
<DialogContent className="max-w-4xl">
<DialogHeader>
@@ -139,13 +363,18 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
{aiValidationDetails.changeDetails && aiValidationDetails.changeDetails.length > 0 ? (
{aiValidationDetails.changeDetails &&
aiValidationDetails.changeDetails.length > 0 ? (
<div className="mb-6 space-y-6">
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
{aiValidationDetails.changeDetails.map((product, i) => {
// Find the title change if it exists
const titleChange = product.changes.find(c => c.field === 'title');
const titleValue = titleChange ? titleChange.corrected : product.title;
const titleChange = product.changes.find(
(c) => c.field === "title"
);
const titleValue = titleChange
? titleChange.corrected
: product.title;
return (
<div key={`product-${i}`} className="border rounded-md p-4">
@@ -163,12 +392,20 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
</TableHeader>
<TableBody>
{product.changes.map((change, j) => {
const field = fields.find(f => f.key === change.field);
const fieldLabel = field ? field.label : change.field;
const isReverted = isChangeReverted(product.productIndex, change.field);
const field = fields.find(
(f) => f.key === change.field
);
const fieldLabel = field
? field.label
: change.field;
const isReverted = isChangeReverted(
product.productIndex,
change.field
);
// Get highlighted differences
const { originalHtml, correctedHtml } = getFieldDisplayValueWithHighlight(
const { originalHtml, correctedHtml } =
getFieldDisplayValueWithHighlight(
change.field,
change.original,
change.corrected
@@ -176,16 +413,22 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
return (
<TableRow key={`change-${j}`}>
<TableCell className="font-medium">{fieldLabel}</TableCell>
<TableCell className="font-medium">
{fieldLabel}
</TableCell>
<TableCell>
<div
dangerouslySetInnerHTML={{ __html: originalHtml }}
dangerouslySetInnerHTML={{
__html: originalHtml,
}}
className={isReverted ? "font-medium" : ""}
/>
</TableCell>
<TableCell>
<div
dangerouslySetInnerHTML={{ __html: correctedHtml }}
dangerouslySetInnerHTML={{
__html: correctedHtml,
}}
className={!isReverted ? "font-medium" : ""}
/>
</TableCell>
@@ -207,7 +450,10 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
size="sm"
onClick={() => {
// Call the revert function directly
revertAiChange(product.productIndex, change.field);
revertAiChange(
product.productIndex,
change.field
);
}}
>
Revert Change
@@ -226,12 +472,17 @@ export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
{aiValidationDetails.warnings && aiValidationDetails.warnings.length > 0 ? (
{aiValidationDetails.warnings &&
aiValidationDetails.warnings.length > 0 ? (
<div>
<p className="mb-4">No changes were made, but the AI provided some warnings:</p>
<p className="mb-4">
No changes were made, but the AI provided some warnings:
</p>
<ul className="list-disc pl-8 text-left">
{aiValidationDetails.warnings.map((warning, i) => (
<li key={`warning-${i}`} className="mb-2">{warning}</li>
<li key={`warning-${i}`} className="mb-2">
{warning}
</li>
))}
</ul>
</div>

View File

@@ -18,6 +18,7 @@ import { useUpcValidation } from '../hooks/useUpcValidation'
import { useProductLinesFetching } from '../hooks/useProductLinesFetching'
import UpcValidationTableAdapter from './UpcValidationTableAdapter'
import { Skeleton } from '@/components/ui/skeleton'
import { Protected } from '@/components/auth/Protected'
/**
* ValidationContainer component - the main wrapper for the validation step
*
@@ -1149,6 +1150,7 @@ const ValidationContainer = <T extends string>({
)}
<div className="flex items-center gap-2">
{/* Show Prompt Button */}
<Protected permission="admin:debug">
<Button
variant="outline"
onClick={aiValidation.showCurrentPrompt}
@@ -1158,6 +1160,7 @@ const ValidationContainer = <T extends string>({
<FileText className="h-4 w-4" />
Show Prompt
</Button>
</Protected>
{/* AI Validate Button */}
<Button
@@ -1200,6 +1203,7 @@ const ValidationContainer = <T extends string>({
isChangeReverted={aiValidation.isChangeReverted}
getFieldDisplayValueWithHighlight={aiValidation.getFieldDisplayValueWithHighlight}
fields={fields}
debugData={aiValidation.currentPrompt.debugData}
/>
{/* Product Search Dialog */}

View File

@@ -42,6 +42,25 @@ export interface CurrentPrompt {
isOpen: boolean;
prompt: string | null;
isLoading: boolean;
debugData?: {
taxonomyStats: {
categories: number;
themes: number;
colors: number;
taxCodes: number;
sizeCategories: number;
suppliers: number;
companies: number;
artists: number;
} | null;
basePrompt: string;
sampleFullPrompt: string;
promptLength: number;
estimatedProcessingTime?: {
seconds: number | null;
sampleCount: number;
};
};
}
// Declare global interface for the timer
@@ -250,7 +269,11 @@ export const useAiValidation = <T extends string>(
// Function to show current prompt
const showCurrentPrompt = useCallback(async () => {
try {
setCurrentPrompt(prev => ({ ...prev, isLoading: true, isOpen: true }));
setCurrentPrompt(prev => ({
...prev,
isLoading: true,
isOpen: true
}));
// Debug log the data being sent
console.log('Sending products data:', {
@@ -272,7 +295,7 @@ export const useAiValidation = <T extends string>(
});
// Use POST to send products in request body
const response = await fetch(`${getApiUrl()}/ai-validation/debug`, {
const response = await fetch(`${await getApiUrl()}/ai-validation/debug`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -294,7 +317,14 @@ export const useAiValidation = <T extends string>(
setCurrentPrompt(prev => ({
...prev,
prompt: promptContent,
isLoading: false
isLoading: false,
debugData: {
taxonomyStats: result.taxonomyStats || null,
basePrompt: result.basePrompt || '',
sampleFullPrompt: result.sampleFullPrompt || '',
promptLength: result.promptLength || (promptContent ? promptContent.length : 0),
estimatedProcessingTime: result.estimatedProcessingTime
}
}));
} else {
throw new Error('No prompt returned from server');

View File

@@ -133,8 +133,9 @@ export function PerformanceMetrics() {
}
};
function getCategoryName(_cat_id: number): import("react").ReactNode {
throw new Error('Function not implemented.');
function getCategoryName(cat_id: number): import("react").ReactNode {
// Simple implementation that just returns the ID as a string
return `Category ${cat_id}`;
}
return (
@@ -217,15 +218,19 @@ export function PerformanceMetrics() {
</TableRow>
</TableHeader>
<TableBody>
{abcConfigs.map((config) => (
{abcConfigs && abcConfigs.length > 0 ? abcConfigs.map((config) => (
<TableRow key={`${config.cat_id}-${config.vendor}`}>
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
<TableCell className="text-right">{config.a_threshold}%</TableCell>
<TableCell className="text-right">{config.b_threshold}%</TableCell>
<TableCell className="text-right">{config.classification_period_days}</TableCell>
<TableCell className="text-right">{config.a_threshold !== undefined ? `${config.a_threshold}%` : '0%'}</TableCell>
<TableCell className="text-right">{config.b_threshold !== undefined ? `${config.b_threshold}%` : '0%'}</TableCell>
<TableCell className="text-right">{config.classification_period_days || 0}</TableCell>
</TableRow>
))}
)) : (
<TableRow>
<TableCell colSpan={5} className="text-center py-4">No ABC configurations available</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Button onClick={handleUpdateABCConfig}>
@@ -253,14 +258,26 @@ export function PerformanceMetrics() {
</TableRow>
</TableHeader>
<TableBody>
{turnoverConfigs.map((config) => (
{turnoverConfigs && turnoverConfigs.length > 0 ? turnoverConfigs.map((config) => (
<TableRow key={`${config.cat_id}-${config.vendor}`}>
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
<TableCell className="text-right">{config.calculation_period_days}</TableCell>
<TableCell className="text-right">{config.target_rate.toFixed(2)}</TableCell>
<TableCell className="text-right">
{config.target_rate !== undefined && config.target_rate !== null
? (typeof config.target_rate === 'number'
? config.target_rate.toFixed(2)
: (isNaN(parseFloat(String(config.target_rate)))
? '0.00'
: parseFloat(String(config.target_rate)).toFixed(2)))
: '0.00'}
</TableCell>
</TableRow>
))}
)) : (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">No turnover configurations available</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Button onClick={handleUpdateTurnoverConfig}>

View File

@@ -0,0 +1,130 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Info } from "lucide-react";
interface Permission {
id: number;
name: string;
code: string;
description?: string;
}
interface PermissionCategory {
category: string;
permissions: Permission[];
}
interface PermissionSelectorProps {
permissionsByCategory: PermissionCategory[];
selectedPermissions: number[];
onChange: (selectedPermissions: number[]) => void;
disabled?: boolean;
}
export function PermissionSelector({
permissionsByCategory,
selectedPermissions,
onChange,
disabled = false
}: PermissionSelectorProps) {
// Handle permission checkbox change
const handlePermissionChange = (permissionId: number) => {
const newSelectedPermissions = selectedPermissions.includes(permissionId)
? selectedPermissions.filter(id => id !== permissionId)
: [...selectedPermissions, permissionId];
onChange(newSelectedPermissions);
};
// Handle selecting/deselecting all permissions in a category
const handleSelectCategory = (category: string) => {
const categoryData = permissionsByCategory.find(c => c.category === category);
if (!categoryData) return;
const categoryPermIds = categoryData.permissions.map(p => p.id);
// Check if all permissions in category are already selected
const allSelected = categoryPermIds.every(id => selectedPermissions.includes(id));
// If all selected, deselect all; otherwise select all
const newSelectedPermissions = allSelected
? selectedPermissions.filter(id => !categoryPermIds.includes(id))
: [...new Set([...selectedPermissions, ...categoryPermIds])];
onChange(newSelectedPermissions);
};
// Check if there are no permission categories
if (!permissionsByCategory || permissionsByCategory.length === 0) {
return (
<div className="text-center py-4">
<p className="text-muted-foreground">No permissions available</p>
</div>
);
}
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">Permissions</h3>
<p className="text-sm text-muted-foreground mb-4">
Select the permissions you want to grant to this user
</p>
{permissionsByCategory.map(category => (
<Card key={category.category} className="mb-4">
<CardHeader className="pb-2 flex flex-row items-center justify-between">
<CardTitle className="text-md">{category.category}</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleSelectCategory(category.category)}
disabled={disabled}
>
{category.permissions.every(p => selectedPermissions.includes(p.id))
? 'Deselect All'
: 'Select All'}
</Button>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{category.permissions.map(permission => (
<div key={permission.id} className="flex items-center space-x-2 py-1">
<Checkbox
id={`permission-${permission.id}`}
checked={selectedPermissions.includes(permission.id)}
onCheckedChange={() => handlePermissionChange(permission.id)}
disabled={disabled}
/>
<label
htmlFor={`permission-${permission.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex items-center"
>
{permission.name}
{permission.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 ml-1 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>{permission.description}</p>
<p className="text-xs text-muted-foreground mt-1">Code: {permission.code}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</label>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,353 @@
import { useState, useEffect, useContext } from "react";
import { Card, CardContent, 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 } = 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: { id: any; }) => 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>
</div>
<Button onClick={handleAddUser}>
Add User
</Button>
</CardHeader>
<CardContent>
<UserList
users={users}
onEdit={handleEditUser}
onDelete={handleDeleteUser}
/>
</CardContent>
</>
)}
</Card>
);
}

View File

@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,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>
);
}

View File

@@ -1,200 +0,0 @@
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Code } from "@/components/ui/code"
import { useToast } from "@/hooks/use-toast"
import { Loader2 } from "lucide-react"
import config from "@/config"
interface TaxonomyStats {
categories: number
themes: number
colors: number
taxCodes: number
sizeCategories: number
suppliers: number
companies: number
artists: number
}
interface DebugData {
taxonomyStats: TaxonomyStats | null
basePrompt: string
sampleFullPrompt: string
promptLength: number
estimatedProcessingTime?: {
seconds: number | null
sampleCount: number
}
}
export function AiValidationDebug() {
const [isLoading, setIsLoading] = useState(false)
const [debugData, setDebugData] = useState<DebugData | null>(null)
const { toast } = useToast()
const fetchDebugData = async () => {
setIsLoading(true)
try {
// Use a sample product to avoid loading full taxonomy
const sampleProduct = {
title: "Sample Product",
description: "A sample product for testing",
SKU: "SAMPLE-001",
price: "9.99",
cost_each: "5.00",
qty_per_unit: "1",
case_qty: "12"
}
const response = await fetch(`${config.apiUrl}/ai-validation/debug`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ products: [sampleProduct] })
})
if (!response.ok) {
throw new Error('Failed to fetch debug data')
}
const data = await response.json()
setDebugData(data)
} catch (error) {
console.error('Error fetching debug data:', error)
toast({
variant: "destructive",
title: "Error",
description: error instanceof Error ? error.message : "Failed to fetch debug data"
})
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchDebugData()
}, [])
return (
<div className="container mx-auto py-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">AI Validation Debug</h1>
<div className="space-x-4">
<Button
variant="outline"
onClick={fetchDebugData}
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Refresh Data
</Button>
</div>
</div>
{debugData && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Taxonomy Stats</CardTitle>
</CardHeader>
<CardContent>
{debugData.taxonomyStats ? (
<div className="space-y-2">
<div>Categories: {debugData.taxonomyStats.categories}</div>
<div>Themes: {debugData.taxonomyStats.themes}</div>
<div>Colors: {debugData.taxonomyStats.colors}</div>
<div>Tax Codes: {debugData.taxonomyStats.taxCodes}</div>
<div>Size Categories: {debugData.taxonomyStats.sizeCategories}</div>
<div>Suppliers: {debugData.taxonomyStats.suppliers}</div>
<div>Companies: {debugData.taxonomyStats.companies}</div>
<div>Artists: {debugData.taxonomyStats.artists}</div>
</div>
) : (
<div>No taxonomy data available</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Prompt Length</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="space-y-2">
<div>Characters: {debugData.promptLength}</div>
<div>Tokens (est.): ~{Math.round(debugData.promptLength / 4)}</div>
</div>
<div className="space-y-2">
<label htmlFor="costPerMillion" className="text-sm text-muted-foreground">
Cost per million tokens ($)
</label>
<input
id="costPerMillion"
type="number"
className="w-full px-3 py-2 border rounded-md"
defaultValue="2.50"
onChange={(e) => {
const costPerMillion = parseFloat(e.target.value)
if (!isNaN(costPerMillion)) {
const tokens = Math.round(debugData.promptLength / 4)
const cost = (tokens / 1_000_000) * costPerMillion * 100 // Convert to cents
const costElement = document.getElementById('tokenCost')
if (costElement) {
costElement.textContent = cost.toFixed(1)
}
}
}}
/>
<div className="text-sm">
Cost: <span id="tokenCost">{((Math.round(debugData.promptLength / 4) / 1_000_000) * 3 * 100).toFixed(1)}</span>¢
</div>
</div>
{debugData.estimatedProcessingTime && (
<div className="mt-4 p-3 bg-muted rounded-md">
<h3 className="text-sm font-medium mb-2">Processing Time Estimate</h3>
{debugData.estimatedProcessingTime.seconds ? (
<div className="space-y-1">
<div className="text-sm">
Estimated time: {formatTime(debugData.estimatedProcessingTime.seconds)}
</div>
<div className="text-xs text-muted-foreground">
Based on {debugData.estimatedProcessingTime.sampleCount} similar validation{debugData.estimatedProcessingTime.sampleCount !== 1 ? 's' : ''}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">No historical data available for this prompt size</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
<Card className="col-span-full">
<CardHeader>
<CardTitle>Full Sample Prompt</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
<Code className="whitespace-pre-wrap">{debugData.sampleFullPrompt}</Code>
</ScrollArea>
</CardContent>
</Card>
</div>
)}
</div>
)
}
// Helper function to format time in a human-readable way
function formatTime(seconds: number): string {
if (seconds < 60) {
return `${Math.round(seconds)} seconds`;
} else {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
}

View File

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

View File

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

View File

@@ -4,49 +4,233 @@ 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";
import { useContext, useMemo } from "react";
import { AuthContext } from "@/contexts/AuthContext";
import { Separator } from "@/components/ui/separator";
// Define types for settings structure
interface SettingsTab {
id: string;
permission: string;
label: string;
}
interface SettingsGroup {
id: string;
label: string;
tabs: SettingsTab[];
}
// Define available settings tabs with their permission requirements and groups
const SETTINGS_GROUPS: SettingsGroup[] = [
{
id: "inventory",
label: "Inventory Settings",
tabs: [
{ id: "stock-management", permission: "settings:stock_management", label: "Stock Management" },
{ id: "performance-metrics", permission: "settings:performance_metrics", label: "Performance Metrics" },
{ id: "calculation-settings", permission: "settings:calculation_settings", label: "Calculation Settings" },
]
},
{
id: "content",
label: "Content Management",
tabs: [
{ id: "templates", permission: "settings:templates", label: "Template Management" },
]
},
{
id: "system",
label: "System",
tabs: [
{ id: "user-management", permission: "settings:user_management", label: "User Management" },
{ id: "data-management", permission: "settings:data_management", label: "Data Management" },
]
}
];
// Flatten tabs for easier access
const SETTINGS_TABS = SETTINGS_GROUPS.flatMap(group => group.tabs);
export function Settings() {
const { user } = useContext(AuthContext);
// Determine the first tab the user has access to
const defaultTab = useMemo(() => {
// Admin users have access to all tabs
if (user?.is_admin) {
return SETTINGS_TABS[0].id;
}
// Find the first tab the user has permission to access
const firstAccessibleTab = SETTINGS_TABS.find(tab =>
user?.permissions?.includes(tab.permission)
);
// Return the ID of the first accessible tab, or first tab as fallback
return firstAccessibleTab?.id || SETTINGS_TABS[0].id;
}, [user]);
// Check if user has access to any tab
const hasAccessToAnyTab = useMemo(() => {
if (user?.is_admin) return true;
return SETTINGS_TABS.some(tab => user?.permissions?.includes(tab.permission));
}, [user]);
// If user doesn't have access to any tabs, show a helpful message
if (!hasAccessToAnyTab) {
return (
<motion.div layout className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Settings</h1>
</div>
<Alert>
<AlertDescription>
You don't have permission to access any settings. Please contact an administrator for assistance.
</AlertDescription>
</Alert>
</motion.div>
);
}
// Function to check if the user has access to any tab in a group
const hasAccessToGroup = (group: SettingsGroup): boolean => {
if (user?.is_admin) return true;
return group.tabs.some(tab => user?.permissions?.includes(tab.permission));
};
return (
<motion.div layout className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Settings</h1>
</div>
<Tabs defaultValue="data-management" className="space-y-4">
<TabsList>
<TabsTrigger value="data-management">Data Management</TabsTrigger>
<TabsTrigger value="stock-management">Stock Management</TabsTrigger>
<TabsTrigger value="performance-metrics">
Performance Metrics
</TabsTrigger>
<TabsTrigger value="calculation-settings">
Calculation Settings
</TabsTrigger>
<TabsTrigger value="templates">
Template Management
<Tabs defaultValue={defaultTab} orientation="vertical" className="flex flex-row min-h-[500px]">
<div className="w-60 border-r pr-8">
<TabsList className="flex flex-col h-auto justify-start items-stretch p-0 bg-transparent">
{SETTINGS_GROUPS.map((group) => (
hasAccessToGroup(group) && (
<div key={group.id} className="">
<h3 className="font-semibold text-sm px-3 py-2 bg-muted border text-foreground rounded-md mb-2">
{group.label}
</h3>
<div className="space-y-1 pl-1">
{group.tabs.map((tab) => (
<Protected key={tab.id} permission={tab.permission}>
<TabsTrigger
value={tab.id}
className="w-full justify-start px-3 py-2 text-sm font-normal text-muted-foreground data-[state=active]:font-medium data-[state=active]:text-accent-foreground data-[state=active]:shadow-none rounded-md data-[state=active]:underline"
>
{tab.label}
</TabsTrigger>
</Protected>
))}
</div>
{/* Only add separator if not the last group */}
{group.id !== SETTINGS_GROUPS[SETTINGS_GROUPS.length - 1].id && (
<Separator className="mt-4 mb-4 opacity-70" />
)}
</div>
)
))}
</TabsList>
</div>
<TabsContent value="data-management">
<div className="pl-8 w-full">
<TabsContent value="data-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:data_management"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access Data Management.
</AlertDescription>
</Alert>
}
>
<DataManagement />
</Protected>
</TabsContent>
<TabsContent value="stock-management">
<TabsContent value="stock-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:stock_management"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access Stock Management.
</AlertDescription>
</Alert>
}
>
<StockManagement />
</Protected>
</TabsContent>
<TabsContent value="performance-metrics">
<TabsContent value="performance-metrics" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:performance_metrics"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access Performance Metrics.
</AlertDescription>
</Alert>
}
>
<PerformanceMetrics />
</Protected>
</TabsContent>
<TabsContent value="calculation-settings">
<TabsContent value="calculation-settings" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:calculation_settings"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access Calculation Settings.
</AlertDescription>
</Alert>
}
>
<CalculationSettings />
</Protected>
</TabsContent>
<TabsContent value="templates">
<TabsContent value="templates" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:templates"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access Template Management.
</AlertDescription>
</Alert>
}
>
<TemplateManagement />
</Protected>
</TabsContent>
<TabsContent value="user-management" className="mt-0 focus-visible:outline-none focus-visible:ring-0">
<Protected
permission="settings:user_management"
fallback={
<Alert>
<AlertDescription>
You don't have permission to access User Management.
</AlertDescription>
</Alert>
}
>
<UserManagement />
</Protected>
</TabsContent>
</div>
</Tabs>
</motion.div>
);