Backend: - Express server with JWT httpOnly cookie auth - POST /api/auth/register, /api/auth/login, /api/auth/logout, GET /api/auth/me - bcrypt 12 rounds, generic 401 errors (no email/password field disclosure) - Auth middleware protects all /api/* routes except register/login - pg Pool database connection Frontend (React + Vite + TailwindCSS + shadcn/ui): - AuthContext with session restore on page load via /api/auth/me - ProtectedRoute redirects unauthenticated users to /login - LoginPage, RegisterPage — Hebrew RTL layout (dir=rtl), inline validation - DashboardPage placeholder - shadcn/ui components: Button, Input, Label, Card Database: - 9 migrations (001-009): extensions, users, events, vendors, guests, bookings, invitations, vendor_ratings, organizer_preferences - pg_trgm for fuzzy Hebrew search, GIN indexes on style_tags - Phase 2+3 fields included: source, payment_status, contract_value, vendor ratings 6-dimension, organizer preferences - Idempotent migration runner with schema_migrations tracking table Infrastructure: - Dockerfile (multi-stage: build React → production node:20-alpine) - docker-compose.yml with PostgreSQL healthcheck, expose not ports - Migrations run automatically on container start Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
5.6 KiB
JavaScript
155 lines
5.6 KiB
JavaScript
const express = require('express');
|
|
const bcrypt = require('bcrypt');
|
|
const jwt = require('jsonwebtoken');
|
|
const pool = require('../db/pool');
|
|
|
|
const router = express.Router();
|
|
|
|
const BCRYPT_ROUNDS = 12; // min 10 per spec; 12 for extra safety
|
|
const JWT_SECRET = process.env.JWT_SECRET;
|
|
const JWT_EXPIRES_IN = '24h';
|
|
const COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; // 24h in ms
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function isValidEmail(email) {
|
|
return typeof email === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
}
|
|
|
|
function setAuthCookie(res, token) {
|
|
res.cookie('token', token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: COOKIE_MAX_AGE,
|
|
});
|
|
}
|
|
|
|
// ─── POST /api/auth/register ──────────────────────────────────────────────────
|
|
|
|
router.post('/register', async (req, res) => {
|
|
const { email, password, display_name, role } = req.body;
|
|
|
|
// 400 — validation
|
|
const errors = [];
|
|
if (!email || !isValidEmail(email)) errors.push('Valid email is required');
|
|
if (!password || password.length < 8) errors.push('Password must be at least 8 characters');
|
|
if (!display_name || display_name.trim().length === 0) errors.push('Display name is required');
|
|
if (role && !['organizer', 'vendor'].includes(role)) errors.push('Role must be organizer or vendor');
|
|
|
|
if (errors.length > 0) {
|
|
return res.status(400).json({ error: errors.join('; ') });
|
|
}
|
|
|
|
const userRole = role || 'organizer';
|
|
|
|
try {
|
|
// 409 — duplicate email
|
|
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email.toLowerCase()]);
|
|
if (existing.rows.length > 0) {
|
|
return res.status(409).json({ error: 'An account with this email already exists' });
|
|
}
|
|
|
|
const password_hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
|
|
const result = await pool.query(
|
|
`INSERT INTO users (email, password_hash, display_name, role)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, email, display_name, role, created_at`,
|
|
[email.toLowerCase(), password_hash, display_name.trim(), userRole]
|
|
);
|
|
|
|
const user = result.rows[0];
|
|
const token = jwt.sign(
|
|
{ id: user.id, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRES_IN }
|
|
);
|
|
|
|
setAuthCookie(res, token);
|
|
|
|
return res.status(201).json({
|
|
user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role },
|
|
});
|
|
} catch (err) {
|
|
console.error('Registration error:', err.message);
|
|
return res.status(500).json({ error: 'Registration failed. Please try again.' });
|
|
}
|
|
});
|
|
|
|
// ─── POST /api/auth/login ─────────────────────────────────────────────────────
|
|
|
|
router.post('/login', async (req, res) => {
|
|
const { email, password } = req.body;
|
|
|
|
// 400 — validation
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: 'Email and password are required' });
|
|
}
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
'SELECT id, email, password_hash, display_name, role FROM users WHERE email = $1',
|
|
[email.toLowerCase()]
|
|
);
|
|
|
|
// Generic 401 — do not reveal whether email or password was wrong
|
|
if (result.rows.length === 0) {
|
|
await bcrypt.compare(password, '$2b$12$fakehashtopreventtimingattacks00000000000000000000000'); // timing safe
|
|
return res.status(401).json({ error: 'Invalid email or password' });
|
|
}
|
|
|
|
const user = result.rows[0];
|
|
const valid = await bcrypt.compare(password, user.password_hash);
|
|
|
|
if (!valid) {
|
|
return res.status(401).json({ error: 'Invalid email or password' });
|
|
}
|
|
|
|
const token = jwt.sign(
|
|
{ id: user.id, email: user.email, role: user.role },
|
|
JWT_SECRET,
|
|
{ expiresIn: JWT_EXPIRES_IN }
|
|
);
|
|
|
|
setAuthCookie(res, token);
|
|
|
|
return res.json({
|
|
user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role },
|
|
});
|
|
} catch (err) {
|
|
console.error('Login error:', err.message);
|
|
return res.status(500).json({ error: 'Login failed. Please try again.' });
|
|
}
|
|
});
|
|
|
|
// ─── POST /api/auth/logout ────────────────────────────────────────────────────
|
|
|
|
router.post('/logout', (req, res) => {
|
|
res.clearCookie('token', { httpOnly: true, sameSite: 'lax' });
|
|
return res.json({ message: 'Logged out successfully' });
|
|
});
|
|
|
|
// ─── GET /api/auth/me ─────────────────────────────────────────────────────────
|
|
// Returns current user from cookie — useful for session restore on page refresh
|
|
|
|
const { authMiddleware } = require('../middleware/auth');
|
|
|
|
router.get('/me', authMiddleware, async (req, res) => {
|
|
try {
|
|
const result = await pool.query(
|
|
'SELECT id, email, display_name, role FROM users WHERE id = $1',
|
|
[req.user.id]
|
|
);
|
|
if (result.rows.length === 0) {
|
|
return res.status(401).json({ error: 'User not found' });
|
|
}
|
|
return res.json({ user: result.rows[0] });
|
|
} catch (err) {
|
|
console.error('Me endpoint error:', err.message);
|
|
return res.status(500).json({ error: 'Failed to retrieve user' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|