Files
shokuninmarche/routes/auth.js
airewit-developer c8909befb1 feat: Foundation — auth system, 9 migrations, React frontend
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>
2026-02-21 18:22:42 +00:00

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;