const express = require('express'); const bcrypt = require('bcryptjs'); const { query, formatPrice } = require('../db'); const { getCart, cartCount } = require('../cart'); const { requireAuth } = require('../middleware/auth'); const { requireCookieConsent } = require('../middleware/cookieConsent'); const { ROLES, ROLE_LABELS } = require('../constants/roles'); const { asyncHandler } = require('../utils/asyncHandler'); const { expireOldReservations } = require('../services/reservations'); const webauthn = require('../services/webauthn'); const router = express.Router(); router.use(requireCookieConsent); router.use((req, res, next) => { const cart = getCart(req); res.locals.cartCount = cartCount(cart); res.locals.formatPrice = formatPrice; next(); }); async function loadAccountUser(userId) { const { rows } = await query( 'SELECT id, email, name, role, created_at, passkey_enabled, loyalty_points FROM users WHERE id = $1', [userId] ); return rows[0]; } async function verifyPassword(userId, password) { const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [ userId, ]); if (!rows[0]) return false; return bcrypt.compareSync(password || '', rows[0].password_hash); } function accountRender(res, options) { const { user, orderCount, reservations, error, success, activeTab, formatPrice, passkeys, isAdmin, } = options; res.render('account/index', { title: 'Личный кабинет', user, orderCount, reservations: reservations || [], passkeys: passkeys || [], isAdmin: Boolean(isAdmin), roleLabels: ROLE_LABELS, formatPrice: formatPrice || res.locals.formatPrice, error: error || null, success: success || null, activeTab: activeTab || 'profile', }); } router.get( '/', requireAuth, asyncHandler(async (req, res) => { await expireOldReservations(); const user = await loadAccountUser(req.session.userId); const countResult = await query( 'SELECT COUNT(*)::int AS n FROM orders WHERE user_id = $1', [user.id] ); const { rows: reservations } = await query( `SELECT r.*, p.name AS product_name, p.slug AS product_slug, p.price_cents, p.image_url FROM reservations r JOIN products p ON p.id = r.product_id WHERE r.user_id = $1 ORDER BY r.created_at DESC`, [user.id] ); const passkeys = await webauthn.getCredentialsForUser(user.id); accountRender(res, { user, orderCount: countResult.rows[0].n, reservations, passkeys, isAdmin: user.role === ROLES.ADMIN, formatPrice, success: req.query.success ? decodeURIComponent(String(req.query.success)) : null, error: req.query.error ? decodeURIComponent(String(req.query.error)) : null, activeTab: req.query.tab || 'profile', }); }) ); router.post( '/profile', requireAuth, asyncHandler(async (req, res) => { const name = (req.body.name || '').trim(); if (!name) { return res.redirect('/account?tab=profile&error=' + encodeURIComponent('Укажите имя')); } await query('UPDATE users SET name = $1 WHERE id = $2', [name, req.session.userId]); res.redirect('/account?tab=profile&success=' + encodeURIComponent('Имя обновлено')); }) ); router.post( '/email', requireAuth, asyncHandler(async (req, res) => { const newEmail = (req.body.email || '').trim().toLowerCase(); const { current_password } = req.body; if (!newEmail) { return res.redirect( '/account?tab=email&error=' + encodeURIComponent('Укажите новый email') ); } const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRe.test(newEmail)) { return res.redirect( '/account?tab=email&error=' + encodeURIComponent('Некорректный email') ); } const user = await loadAccountUser(req.session.userId); if (newEmail === user.email) { return res.redirect( '/account?tab=email&error=' + encodeURIComponent('Это уже ваш текущий email') ); } if (!(await verifyPassword(req.session.userId, current_password))) { return res.redirect( '/account?tab=email&error=' + encodeURIComponent('Неверный текущий пароль') ); } try { await query('UPDATE users SET email = $1 WHERE id = $2', [ newEmail, req.session.userId, ]); res.redirect('/account?tab=email&success=' + encodeURIComponent('Email изменён')); } catch (err) { if (err.code === '23505') { return res.redirect( '/account?tab=email&error=' + encodeURIComponent('Этот email уже занят') ); } throw err; } }) ); router.post( '/password', requireAuth, asyncHandler(async (req, res) => { const { current_password, password, password2 } = req.body; if (!(await verifyPassword(req.session.userId, current_password))) { return res.redirect( '/account?tab=password&error=' + encodeURIComponent('Неверный текущий пароль') ); } if (!password || password.length < 6) { return res.redirect( '/account?tab=password&error=' + encodeURIComponent('Новый пароль не менее 6 символов') ); } if (password !== password2) { return res.redirect( '/account?tab=password&error=' + encodeURIComponent('Пароли не совпадают') ); } const hash = bcrypt.hashSync(password, 10); await query('UPDATE users SET password_hash = $1 WHERE id = $2', [ hash, req.session.userId, ]); res.redirect('/account?tab=password&success=' + encodeURIComponent('Пароль изменён')); }) ); router.post( '/passkey/disable', requireAuth, asyncHandler(async (req, res) => { const { current_password } = req.body; if (!(await verifyPassword(req.session.userId, current_password))) { return res.redirect( '/account?tab=passkey&error=' + encodeURIComponent('Неверный пароль') ); } await webauthn.disablePasskeys(req.session.userId); res.redirect( '/account?tab=passkey&success=' + encodeURIComponent('Вход по passkey отключён') ); }) ); router.post( '/passkey/credentials/:id/delete', requireAuth, asyncHandler(async (req, res) => { const { current_password } = req.body; if (!(await verifyPassword(req.session.userId, current_password))) { return res.redirect( '/account?tab=passkey&error=' + encodeURIComponent('Неверный пароль') ); } const credId = parseInt(req.params.id, 10); if (!Number.isFinite(credId)) { return res.redirect('/account?tab=passkey&error=' + encodeURIComponent('Некорректный ключ')); } const ok = await webauthn.deleteCredential(req.session.userId, credId); if (!ok) { return res.redirect('/account?tab=passkey&error=' + encodeURIComponent('Ключ не найден')); } res.redirect('/account?tab=passkey&success=' + encodeURIComponent('Passkey удалён')); }) ); module.exports = router;