diff --git a/src/public/css/style.css b/src/public/css/style.css index 804aafb..f846193 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -671,3 +671,62 @@ a:hover { font-size: 0.85rem; width: auto; } + +.account-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.account-tabs__link { + padding: 0.45rem 1rem; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; +} + +.account-tabs__link:hover, +.account-tabs__link--active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.account-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.25rem; +} + +.account-section { + padding: 1.25rem; +} + +.account-section--narrow { + max-width: 420px; +} + +.account-section h2 { + margin: 0 0 1rem; + font-size: 1.1rem; +} + +.profile-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5rem 1.25rem; + margin: 0 0 1.25rem; +} + +.profile-dl dt { + color: var(--muted); + font-size: 0.9rem; +} + +.profile-dl dd { + margin: 0; +} diff --git a/src/routes/account.js b/src/routes/account.js new file mode 100644 index 0000000..6e19568 --- /dev/null +++ b/src/routes/account.js @@ -0,0 +1,163 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { query, formatPrice } = require('../db'); +const { getCart, cartCount } = require('../cart'); +const { requireAuth } = require('../middleware/auth'); +const { ROLE_LABELS } = require('../constants/roles'); +const { asyncHandler } = require('../utils/asyncHandler'); + +const router = express.Router(); + +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 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, error, success, activeTab } = options; + res.render('account/index', { + title: 'Личный кабинет', + user, + orderCount, + roleLabels: ROLE_LABELS, + error: error || null, + success: success || null, + activeTab: activeTab || 'profile', + }); +} + +router.get( + '/', + requireAuth, + asyncHandler(async (req, res) => { + const user = await loadAccountUser(req.session.userId); + const countResult = await query( + 'SELECT COUNT(*)::int AS n FROM orders WHERE user_id = $1', + [user.id] + ); + accountRender(res, { + user, + orderCount: countResult.rows[0].n, + 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('Пароль изменён')); + }) +); + +module.exports = router; diff --git a/src/routes/auth.js b/src/routes/auth.js index ded0d10..8e4fc74 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -115,27 +115,4 @@ router.post('/logout', (req, res) => { }); }); -router.get( - '/account', - requireAuth, - asyncHandler(async (req, res) => { - const { rows } = await query( - 'SELECT id, email, name, role, created_at FROM users WHERE id = $1', - [req.session.userId] - ); - const user = rows[0]; - - const countResult = await query( - 'SELECT COUNT(*)::int AS n FROM orders WHERE user_id = $1', - [user.id] - ); - - res.render('account', { - title: 'Личный кабинет', - user, - orderCount: countResult.rows[0].n, - }); - }) -); - module.exports = router; diff --git a/src/server.js b/src/server.js index 51e40e2..cc1f2a7 100644 --- a/src/server.js +++ b/src/server.js @@ -10,6 +10,7 @@ const { loadUser } = require('./middleware/auth'); const healthRoutes = require('./routes/health'); const shopRoutes = require('./routes/shop'); const authRoutes = require('./routes/auth'); +const accountRoutes = require('./routes/account'); const adminRoutes = require('./routes/admin'); const PORT = process.env.PORT || 3000; @@ -57,6 +58,7 @@ async function start() { app.use(loadUser); app.use('/', shopRoutes); app.use('/', authRoutes); + app.use('/account', accountRoutes); app.use('/admin', adminRoutes); app.use((req, res) => { diff --git a/src/views/account.ejs b/src/views/account.ejs deleted file mode 100644 index 38606cd..0000000 --- a/src/views/account.ejs +++ /dev/null @@ -1,17 +0,0 @@ -<%- include('partials/layout-start') %> - -
-

Личный кабинет

-
-

<%= user.name %>

-

<%= user.email %>

- <% const roleLabels = { customer: 'Клиент', admin: 'Администратор' }; %> -

<%= roleLabels[user.role] || user.role %>

-

С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %>

- -
-
- -<%- include('partials/layout-end') %> diff --git a/src/views/account/index.ejs b/src/views/account/index.ejs new file mode 100644 index 0000000..e051d36 --- /dev/null +++ b/src/views/account/index.ejs @@ -0,0 +1,87 @@ +<%- include('../partials/layout-start') %> + +
+

Личный кабинет

+ + <% if (success) { %>

<%= success %>

<% } %> + <% if (error) { %>

<%= error %>

<% } %> + + + + <% if (activeTab === 'profile') { %> +
+ + + +
+ <% } %> + + <% if (activeTab === 'email') { %> +
+

Смена email

+

Текущий email: <%= user.email %>

+
+ + + +
+
+ <% } %> + + <% if (activeTab === 'password') { %> +
+

Смена пароля

+
+ + + + +
+
+ <% } %> +
+ +<%- include('../partials/layout-end') %>