feat: профиль — просмотр, смена имени, email и пароля

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 11:29:30 +03:00
parent f24f35d0fc
commit 14e0e875f1
6 changed files with 311 additions and 40 deletions
+59
View File
@@ -671,3 +671,62 @@ a:hover {
font-size: 0.85rem; font-size: 0.85rem;
width: auto; 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;
}
+163
View File
@@ -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;
-23
View File
@@ -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; module.exports = router;
+2
View File
@@ -10,6 +10,7 @@ const { loadUser } = require('./middleware/auth');
const healthRoutes = require('./routes/health'); const healthRoutes = require('./routes/health');
const shopRoutes = require('./routes/shop'); const shopRoutes = require('./routes/shop');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const accountRoutes = require('./routes/account');
const adminRoutes = require('./routes/admin'); const adminRoutes = require('./routes/admin');
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -57,6 +58,7 @@ async function start() {
app.use(loadUser); app.use(loadUser);
app.use('/', shopRoutes); app.use('/', shopRoutes);
app.use('/', authRoutes); app.use('/', authRoutes);
app.use('/account', accountRoutes);
app.use('/admin', adminRoutes); app.use('/admin', adminRoutes);
app.use((req, res) => { app.use((req, res) => {
-17
View File
@@ -1,17 +0,0 @@
<%- include('partials/layout-start') %>
<div class="account">
<h1>Личный кабинет</h1>
<div class="card account-card">
<p><strong><%= user.name %></strong></p>
<p class="muted"><%= user.email %></p>
<% const roleLabels = { customer: 'Клиент', admin: 'Администратор' }; %>
<p><span class="role-badge role-badge--<%= user.role %>"><%= roleLabels[user.role] || user.role %></span></p>
<p class="muted">С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %></p>
<div class="account-actions">
<a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a>
</div>
</div>
</div>
<%- include('partials/layout-end') %>
+87
View File
@@ -0,0 +1,87 @@
<%- include('../partials/layout-start') %>
<div class="account">
<h1>Личный кабинет</h1>
<% if (success) { %><p class="alert alert--success"><%= success %></p><% } %>
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
<nav class="account-tabs" aria-label="Разделы профиля">
<a href="/account?tab=profile" class="account-tabs__link <%= activeTab === 'profile' ? 'account-tabs__link--active' : '' %>">Профиль</a>
<a href="/account?tab=email" class="account-tabs__link <%= activeTab === 'email' ? 'account-tabs__link--active' : '' %>">Смена email</a>
<a href="/account?tab=password" class="account-tabs__link <%= activeTab === 'password' ? 'account-tabs__link--active' : '' %>">Смена пароля</a>
</nav>
<% if (activeTab === 'profile') { %>
<div class="account-grid">
<section class="card account-section">
<h2>Мой профиль</h2>
<dl class="profile-dl">
<dt>ID</dt>
<dd><%= user.id %></dd>
<dt>Email</dt>
<dd><%= user.email %></dd>
<dt>Роль</dt>
<dd><span class="role-badge role-badge--<%= user.role %>"><%= roleLabels[user.role] || user.role %></span></dd>
<dt>Регистрация</dt>
<dd><%= new Date(user.created_at).toLocaleString('ru-RU') %></dd>
<dt>Заказов</dt>
<dd><%= orderCount %></dd>
</dl>
<a href="/orders" class="btn btn--primary">Мои заказы</a>
</section>
<section class="card account-section">
<h2>Изменить имя</h2>
<form action="/account/profile" method="post" class="form">
<label class="label">
Имя
<input type="text" name="name" class="input" required value="<%= user.name %>" autocomplete="name">
</label>
<button type="submit" class="btn btn--primary">Сохранить</button>
</form>
</section>
</div>
<% } %>
<% if (activeTab === 'email') { %>
<section class="card account-section account-section--narrow">
<h2>Смена email</h2>
<p class="muted">Текущий email: <strong><%= user.email %></strong></p>
<form action="/account/email" method="post" class="form">
<label class="label">
Новый email
<input type="email" name="email" class="input" required autocomplete="email">
</label>
<label class="label">
Текущий пароль (подтверждение)
<input type="password" name="current_password" class="input" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn--primary">Изменить email</button>
</form>
</section>
<% } %>
<% if (activeTab === 'password') { %>
<section class="card account-section account-section--narrow">
<h2>Смена пароля</h2>
<form action="/account/password" method="post" class="form">
<label class="label">
Текущий пароль
<input type="password" name="current_password" class="input" required autocomplete="current-password">
</label>
<label class="label">
Новый пароль
<input type="password" name="password" class="input" required minlength="6" autocomplete="new-password">
</label>
<label class="label">
Повторите новый пароль
<input type="password" name="password2" class="input" required minlength="6" autocomplete="new-password">
</label>
<button type="submit" class="btn btn--primary">Изменить пароль</button>
</form>
</section>
<% } %>
</div>
<%- include('../partials/layout-end') %>