Add user authentication, RBAC, and improved recruiter dashboard
- JWT-based auth with access tokens (15m) and refresh tokens (7d) - User registration, login, logout, and /auth/me endpoints - Three roles: admin, recruiter, hiring_manager with middleware enforcement - users and refresh_tokens tables with bcrypt password hashing - Login and Register pages with full form validation - Protected routes — unauthenticated users redirect to /login - Dashboard upgraded: real metrics, pipeline overview with progress bars, recent activity feed with 30s polling, and quick-action cards - Dashboard API endpoints: /api/dashboard/metrics, pipeline-summary, recent-activity Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
207
server.js
207
server.js
@@ -4,9 +4,14 @@ const redis = require('redis');
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcryptjs = require('bcryptjs');
|
||||
const bcrypt = bcryptjs;
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'hireflow-secret-key-change-in-production';
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'hireflow-refresh-secret-change-in-production';
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
@@ -83,6 +88,27 @@ async function initDb() {
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(job_id, candidate_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'recruiter',
|
||||
agency_id INTEGER,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
revoked_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
console.log('Database schema initialized');
|
||||
} catch (err) {
|
||||
@@ -136,6 +162,187 @@ function parseCV(text) {
|
||||
};
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: 'Access token required' });
|
||||
try {
|
||||
const user = jwt.verify(token, JWT_SECRET);
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
function requireRole(...roles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user || !roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// POST /auth/register
|
||||
app.post('/auth/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, role } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
const allowedRoles = ['admin', 'recruiter', 'hiring_manager'];
|
||||
const userRole = allowedRoles.includes(role) ? role : 'recruiter';
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const result = await pool.query(
|
||||
'INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id, email, role, created_at',
|
||||
[email.toLowerCase(), passwordHash, userRole]
|
||||
);
|
||||
res.status(201).json({ success: true, user: result.rows[0] });
|
||||
} catch (err) {
|
||||
if (err.code === '23505') return res.status(409).json({ error: 'Email already registered' });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /auth/login
|
||||
app.post('/auth/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
||||
const result = await pool.query('SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL', [email.toLowerCase()]);
|
||||
if (!result.rows.length) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const user = result.rows[0];
|
||||
if (!user.is_active) return res.status(401).json({ error: 'Account is disabled' });
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
const accessToken = jwt.sign({ id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '15m' });
|
||||
const refreshToken = jwt.sign({ id: user.id }, JWT_REFRESH_SECRET, { expiresIn: '7d' });
|
||||
const refreshHash = await bcrypt.hash(refreshToken, 10);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
await pool.query('INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', [user.id, refreshHash, expiresAt]);
|
||||
res.json({ success: true, accessToken, refreshToken, user: { id: user.id, email: user.email, role: user.role } });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /auth/refresh
|
||||
app.post('/auth/refresh', async (req, res) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) return res.status(401).json({ error: 'Refresh token required' });
|
||||
let payload;
|
||||
try { payload = jwt.verify(refreshToken, JWT_REFRESH_SECRET); } catch { return res.status(401).json({ error: 'Invalid refresh token' }); }
|
||||
const tokens = await pool.query('SELECT * FROM refresh_tokens WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()', [payload.id]);
|
||||
let validToken = null;
|
||||
for (const t of tokens.rows) {
|
||||
if (await bcrypt.compare(refreshToken, t.token_hash)) { validToken = t; break; }
|
||||
}
|
||||
if (!validToken) return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
||||
const user = await pool.query('SELECT id, email, role FROM users WHERE id = $1 AND is_active = true AND deleted_at IS NULL', [payload.id]);
|
||||
if (!user.rows.length) return res.status(401).json({ error: 'User not found' });
|
||||
const accessToken = jwt.sign({ id: user.rows[0].id, email: user.rows[0].email, role: user.rows[0].role }, JWT_SECRET, { expiresIn: '15m' });
|
||||
res.json({ success: true, accessToken, user: user.rows[0] });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /auth/logout
|
||||
app.post('/auth/logout', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
await pool.query('UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /auth/me
|
||||
app.get('/auth/me', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT id, email, role, agency_id, is_active, created_at FROM users WHERE id = $1 AND deleted_at IS NULL', [req.user.id]);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/users (admin only)
|
||||
app.get('/api/users', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT id, email, role, agency_id, is_active, created_at FROM users WHERE deleted_at IS NULL ORDER BY created_at DESC');
|
||||
res.json({ users: result.rows });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/users/:id/role (admin only)
|
||||
app.patch('/api/users/:id/role', authenticateToken, requireRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const { role } = req.body;
|
||||
const allowedRoles = ['admin', 'recruiter', 'hiring_manager'];
|
||||
if (!allowedRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
const result = await pool.query('UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL RETURNING id, email, role', [role, req.params.id]);
|
||||
if (!result.rows.length) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({ success: true, user: result.rows[0] });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Add dashboard endpoints
|
||||
app.get('/api/dashboard/metrics', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [candidates, jobs, applications, interviews] = await Promise.all([
|
||||
pool.query('SELECT COUNT(*) FROM candidates'),
|
||||
pool.query("SELECT COUNT(*) FROM jobs WHERE status = 'posted'"),
|
||||
pool.query('SELECT COUNT(*) FROM job_applications'),
|
||||
pool.query("SELECT COUNT(*) FROM scorecards WHERE created_at >= NOW() - INTERVAL '7 days'"),
|
||||
]);
|
||||
res.json({
|
||||
totalCandidates: parseInt(candidates.rows[0].count),
|
||||
activeJobs: parseInt(jobs.rows[0].count),
|
||||
totalApplications: parseInt(applications.rows[0].count),
|
||||
interviewsThisWeek: parseInt(interviews.rows[0].count),
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/dashboard/pipeline-summary', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT stage, COUNT(*) as count FROM job_applications GROUP BY stage');
|
||||
const stages = { applied: 0, screening: 0, interview: 0, offer: 0, hired: 0 };
|
||||
result.rows.forEach(r => { if (stages.hasOwnProperty(r.stage)) stages[r.stage] = parseInt(r.count); });
|
||||
res.json(stages);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/dashboard/recent-activity', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT 'candidate_added' as type, c.name as title, c.email as subtitle, c.created_at as timestamp
|
||||
FROM candidates c
|
||||
UNION ALL
|
||||
SELECT 'job_posted' as type, j.title, j.location as subtitle, j.created_at as timestamp
|
||||
FROM jobs j
|
||||
UNION ALL
|
||||
SELECT 'application' as type, c.name as title, jo.title as subtitle, ja.created_at as timestamp
|
||||
FROM job_applications ja JOIN candidates c ON ja.candidate_id = c.id JOIN jobs jo ON ja.job_id = jo.id
|
||||
ORDER BY timestamp DESC LIMIT 10
|
||||
`);
|
||||
res.json({ activities: result.rows });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user