CI/CD: - Fix build_args format in woodpecker CI (comma-separated, not YAML list) - This fixes "unknown" SHA/version showing on remote deployments Backend schema-tolerant fixes (graceful fallbacks when tables missing): - users.ts: Check which columns exist before querying - worker-registry.ts: Return empty result if table doesn't exist - task-service.ts: Add tableExists() helper, handle missing tables/views - proxies.ts: Return totalProxies in test-all response Frontend fixes: - Proxies: Use total from response for accurate progress display - SEO PagesTab: Dim Generate button when no AI provider active 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
266 lines
9.2 KiB
TypeScript
266 lines
9.2 KiB
TypeScript
import { Router } from 'express';
|
|
import bcrypt from 'bcrypt';
|
|
import { pool } from '../db/pool';
|
|
import { authMiddleware, requireRole, AuthRequest } from '../auth/middleware';
|
|
|
|
const router = Router();
|
|
|
|
// All routes require authentication and admin/superadmin role
|
|
router.use(authMiddleware);
|
|
router.use(requireRole('admin', 'superadmin'));
|
|
|
|
// Get all users with search and filter
|
|
router.get('/', async (req: AuthRequest, res) => {
|
|
try {
|
|
const { search, domain } = req.query;
|
|
|
|
// Check which columns exist (schema-tolerant)
|
|
const columnsResult = await pool.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
|
`);
|
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
|
|
|
// Build column list based on what exists
|
|
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
|
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
|
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
|
if (existingColumns.has('phone')) selectCols.push('phone');
|
|
if (existingColumns.has('domain')) selectCols.push('domain');
|
|
|
|
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
|
|
const params: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
// Search by email (and optionally first_name, last_name if they exist)
|
|
if (search && typeof search === 'string') {
|
|
const searchClauses = ['email ILIKE $' + paramIndex];
|
|
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
|
|
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
|
|
query += ` AND (${searchClauses.join(' OR ')})`;
|
|
params.push(`%${search}%`);
|
|
paramIndex++;
|
|
}
|
|
|
|
// Filter by domain (if column exists)
|
|
if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
|
|
query += ` AND domain = $${paramIndex}`;
|
|
params.push(domain);
|
|
paramIndex++;
|
|
}
|
|
|
|
query += ` ORDER BY created_at DESC`;
|
|
|
|
const result = await pool.query(query, params);
|
|
res.json({ users: result.rows });
|
|
} catch (error) {
|
|
console.error('Error fetching users:', error);
|
|
res.status(500).json({ error: 'Failed to fetch users' });
|
|
}
|
|
});
|
|
|
|
// Get single user
|
|
router.get('/:id', async (req: AuthRequest, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check which columns exist (schema-tolerant)
|
|
const columnsResult = await pool.query(`
|
|
SELECT column_name FROM information_schema.columns
|
|
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
|
`);
|
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
|
|
|
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
|
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
|
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
|
if (existingColumns.has('phone')) selectCols.push('phone');
|
|
if (existingColumns.has('domain')) selectCols.push('domain');
|
|
|
|
const result = await pool.query(`
|
|
SELECT ${selectCols.join(', ')}
|
|
FROM users
|
|
WHERE id = $1
|
|
`, [id]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
res.json({ user: result.rows[0] });
|
|
} catch (error) {
|
|
console.error('Error fetching user:', error);
|
|
res.status(500).json({ error: 'Failed to fetch user' });
|
|
}
|
|
});
|
|
|
|
// Create user
|
|
router.post('/', async (req: AuthRequest, res) => {
|
|
try {
|
|
const { email, password, role, first_name, last_name, phone, domain } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'Email and password are required' });
|
|
}
|
|
|
|
// Check for valid role
|
|
const validRoles = ['admin', 'analyst', 'viewer'];
|
|
if (role && !validRoles.includes(role)) {
|
|
return res.status(400).json({ error: 'Invalid role. Must be: admin, analyst, or viewer' });
|
|
}
|
|
|
|
// Check for valid domain
|
|
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
|
|
if (domain && !validDomains.includes(domain)) {
|
|
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
|
|
}
|
|
|
|
// Check if email already exists
|
|
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
|
if (existing.rows.length > 0) {
|
|
return res.status(400).json({ error: 'Email already exists' });
|
|
}
|
|
|
|
// Hash password
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
|
|
const result = await pool.query(`
|
|
INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
|
`, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']);
|
|
|
|
res.status(201).json({ user: result.rows[0] });
|
|
} catch (error) {
|
|
console.error('Error creating user:', error);
|
|
res.status(500).json({ error: 'Failed to create user' });
|
|
}
|
|
});
|
|
|
|
// Update user
|
|
router.put('/:id', async (req: AuthRequest, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { email, password, role, first_name, last_name, phone, domain } = req.body;
|
|
|
|
// Check if user exists
|
|
const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]);
|
|
if (existing.rows.length === 0) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
|
|
// Check for valid role
|
|
const validRoles = ['admin', 'analyst', 'viewer', 'superadmin'];
|
|
if (role && !validRoles.includes(role)) {
|
|
return res.status(400).json({ error: 'Invalid role' });
|
|
}
|
|
|
|
// Check for valid domain
|
|
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
|
|
if (domain && !validDomains.includes(domain)) {
|
|
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
|
|
}
|
|
|
|
// Prevent non-superadmin from modifying superadmin users
|
|
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
|
|
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
|
|
return res.status(403).json({ error: 'Cannot modify superadmin users' });
|
|
}
|
|
|
|
// Build update query dynamically
|
|
const updates: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (email) {
|
|
// Check if email already taken by another user
|
|
const emailCheck = await pool.query('SELECT id FROM users WHERE email = $1 AND id != $2', [email, id]);
|
|
if (emailCheck.rows.length > 0) {
|
|
return res.status(400).json({ error: 'Email already in use' });
|
|
}
|
|
updates.push(`email = $${paramIndex++}`);
|
|
values.push(email);
|
|
}
|
|
|
|
if (password) {
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
|
updates.push(`password_hash = $${paramIndex++}`);
|
|
values.push(passwordHash);
|
|
}
|
|
|
|
if (role) {
|
|
updates.push(`role = $${paramIndex++}`);
|
|
values.push(role);
|
|
}
|
|
|
|
// Handle profile fields (allow setting to null with explicit undefined check)
|
|
if (first_name !== undefined) {
|
|
updates.push(`first_name = $${paramIndex++}`);
|
|
values.push(first_name || null);
|
|
}
|
|
|
|
if (last_name !== undefined) {
|
|
updates.push(`last_name = $${paramIndex++}`);
|
|
values.push(last_name || null);
|
|
}
|
|
|
|
if (phone !== undefined) {
|
|
updates.push(`phone = $${paramIndex++}`);
|
|
values.push(phone || null);
|
|
}
|
|
|
|
if (domain !== undefined) {
|
|
updates.push(`domain = $${paramIndex++}`);
|
|
values.push(domain);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return res.status(400).json({ error: 'No fields to update' });
|
|
}
|
|
|
|
updates.push(`updated_at = NOW()`);
|
|
values.push(id);
|
|
|
|
const result = await pool.query(`
|
|
UPDATE users
|
|
SET ${updates.join(', ')}
|
|
WHERE id = $${paramIndex}
|
|
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
|
`, values);
|
|
|
|
res.json({ user: result.rows[0] });
|
|
} catch (error) {
|
|
console.error('Error updating user:', error);
|
|
res.status(500).json({ error: 'Failed to update user' });
|
|
}
|
|
});
|
|
|
|
// Delete user
|
|
router.delete('/:id', async (req: AuthRequest, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Prevent deleting yourself
|
|
if (req.user?.id === parseInt(id)) {
|
|
return res.status(400).json({ error: 'Cannot delete your own account' });
|
|
}
|
|
|
|
// Prevent non-superadmin from deleting superadmin users
|
|
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
|
|
if (targetUser.rows.length === 0) {
|
|
return res.status(404).json({ error: 'User not found' });
|
|
}
|
|
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
|
|
return res.status(403).json({ error: 'Cannot delete superadmin users' });
|
|
}
|
|
|
|
await pool.query('DELETE FROM users WHERE id = $1', [id]);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('Error deleting user:', error);
|
|
res.status(500).json({ error: 'Failed to delete user' });
|
|
}
|
|
});
|
|
|
|
export default router;
|