feat: роли customer/admin, админ-панель, admin@site.com

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-17 11:19:01 +03:00
parent 58c789d5f8
commit f24f35d0fc
18 changed files with 497 additions and 9 deletions
+5
View File
@@ -4,6 +4,11 @@ NODE_ENV=production
TRUST_PROXY=1 TRUST_PROXY=1
SESSION_SECRET=change-me-to-a-long-random-string SESSION_SECRET=change-me-to-a-long-random-string
# Первый администратор (создаётся при старте, если email не занят)
ADMIN_EMAIL=admin@site.com
ADMIN_PASSWORD=admin
ADMIN_NAME=Администратор
# PostgreSQL 17 (одна строка или отдельные переменные) # PostgreSQL 17 (одна строка или отдельные переменные)
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
# PGHOST=127.0.0.1 # PGHOST=127.0.0.1
+4
View File
@@ -5,9 +5,13 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer'
CHECK (role IN ('customer', 'admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
CREATE TABLE IF NOT EXISTS categories ( CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
+14
View File
@@ -0,0 +1,14 @@
-- Роли пользователей (миграция для существующих БД)
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'customer';
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check'
) THEN
ALTER TABLE users ADD CONSTRAINT users_role_check
CHECK (role IN ('customer', 'admin'));
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+11
View File
@@ -0,0 +1,11 @@
const ROLES = {
CUSTOMER: 'customer',
ADMIN: 'admin',
};
const ROLE_LABELS = {
customer: 'Клиент',
admin: 'Администратор',
};
module.exports = { ROLES, ROLE_LABELS };
+6 -3
View File
@@ -26,9 +26,12 @@ async function query(text, params) {
} }
async function initSchema() { async function initSchema() {
const schemaPath = path.join(__dirname, '..', 'postgres', 'init', '01_schema.sql'); const initDir = path.join(__dirname, '..', 'postgres', 'init');
const sql = fs.readFileSync(schemaPath, 'utf8'); const files = fs.readdirSync(initDir).filter((f) => f.endsWith('.sql')).sort();
await pool.query(sql); for (const file of files) {
const sql = fs.readFileSync(path.join(initDir, file), 'utf8');
await pool.query(sql);
}
} }
async function checkConnection() { async function checkConnection() {
+19 -2
View File
@@ -1,5 +1,6 @@
const { query } = require('../db'); const { query } = require('../db');
const { asyncHandler } = require('../utils/asyncHandler'); const { asyncHandler } = require('../utils/asyncHandler');
const { ROLES } = require('../constants/roles');
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
if (!req.session.userId) { if (!req.session.userId) {
@@ -9,17 +10,33 @@ function requireAuth(req, res, next) {
next(); next();
} }
function requireAdmin(req, res, next) {
if (!req.session.userId) {
const nextUrl = encodeURIComponent(req.originalUrl);
return res.redirect(`/login?next=${nextUrl}`);
}
if (res.locals.user?.role !== ROLES.ADMIN) {
return res.status(403).render('error', {
title: 'Доступ запрещён',
message: 'Недостаточно прав. Требуется роль администратора.',
code: 403,
});
}
next();
}
const loadUser = asyncHandler(async (req, res, next) => { const loadUser = asyncHandler(async (req, res, next) => {
if (req.session.userId) { if (req.session.userId) {
const { rows } = await query( const { rows } = await query(
'SELECT id, email, name FROM users WHERE id = $1', 'SELECT id, email, name, role FROM users WHERE id = $1',
[req.session.userId] [req.session.userId]
); );
res.locals.user = rows[0] || null; res.locals.user = rows[0] || null;
} else { } else {
res.locals.user = null; res.locals.user = null;
} }
res.locals.isAdmin = res.locals.user?.role === ROLES.ADMIN;
next(); next();
}); });
module.exports = { requireAuth, loadUser }; module.exports = { requireAuth, requireAdmin, loadUser };
+92
View File
@@ -579,3 +579,95 @@ a:hover {
display: inline; display: inline;
margin: 0; margin: 0;
} }
.nav__admin {
color: var(--warn);
font-weight: 600;
}
.admin-header {
margin-bottom: 1.5rem;
}
.admin-header h1 {
margin: 0 0 0.75rem;
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.admin-nav__link {
padding: 0.4rem 0.85rem;
border-radius: 8px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
}
.admin-nav__link:hover,
.admin-nav__link--active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
text-decoration: none;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
}
.stat-card__label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.35rem;
}
.stat-card__value {
font-size: 1.35rem;
}
.role-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
}
.role-badge--customer {
background: rgba(108, 92, 231, 0.2);
color: var(--accent-hover);
}
.role-badge--admin {
background: rgba(253, 203, 110, 0.25);
color: var(--warn);
}
.admin-status-form {
display: flex;
gap: 0.35rem;
align-items: center;
}
.input--sm {
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
width: auto;
}
+107
View File
@@ -0,0 +1,107 @@
const express = require('express');
const { query, formatPrice } = require('../db');
const { requireAdmin } = require('../middleware/auth');
const { asyncHandler } = require('../utils/asyncHandler');
const { ROLE_LABELS } = require('../constants/roles');
const router = express.Router();
router.use(requireAdmin);
router.get(
'/',
asyncHandler(async (req, res) => {
const [users, products, orders, revenue] = await Promise.all([
query('SELECT COUNT(*)::int AS n FROM users'),
query('SELECT COUNT(*)::int AS n FROM products'),
query('SELECT COUNT(*)::int AS n FROM orders'),
query(
`SELECT COALESCE(SUM(total_cents), 0)::int AS total FROM orders WHERE status != 'cancelled'`
),
]);
const { rows: recentOrders } = await query(
`SELECT o.id, o.status, o.total_cents, o.created_at, o.customer_name, u.email AS user_email
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY o.created_at DESC
LIMIT 10`
);
res.render('admin/dashboard', {
title: 'Админ-панель',
stats: {
users: users.rows[0].n,
products: products.rows[0].n,
orders: orders.rows[0].n,
revenue: revenue.rows[0].total,
},
recentOrders,
formatPrice,
});
})
);
router.get(
'/users',
asyncHandler(async (req, res) => {
const { rows: users } = await query(
`SELECT id, email, name, role, created_at FROM users ORDER BY created_at DESC`
);
res.render('admin/users', {
title: 'Пользователи',
users,
roleLabels: ROLE_LABELS,
});
})
);
router.get(
'/orders',
asyncHandler(async (req, res) => {
const { rows: orders } = await query(
`SELECT o.id, o.status, o.total_cents, o.created_at, o.customer_name, o.customer_email,
u.email AS account_email
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY o.created_at DESC`
);
res.render('admin/orders', {
title: 'Заказы',
orders,
formatPrice,
});
})
);
router.post(
'/orders/:id/status',
asyncHandler(async (req, res) => {
const { status } = req.body;
const allowed = ['pending', 'paid', 'shipped', 'cancelled'];
if (!allowed.includes(status)) {
return res.redirect('/admin/orders');
}
await query('UPDATE orders SET status = $1 WHERE id = $2', [status, req.params.id]);
res.redirect('/admin/orders');
})
);
router.get(
'/products',
asyncHandler(async (req, res) => {
const { rows: products } = await query(
`SELECT p.*, c.name AS category_name
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
ORDER BY p.id`
);
res.render('admin/products', {
title: 'Товары',
products,
formatPrice,
});
})
);
module.exports = router;
+8 -3
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcryptjs');
const { query, formatPrice } = require('../db'); const { query, formatPrice } = require('../db');
const { getCart, cartCount } = require('../cart'); const { getCart, cartCount } = require('../cart');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { ROLES } = require('../constants/roles');
const { asyncHandler } = require('../utils/asyncHandler'); const { asyncHandler } = require('../utils/asyncHandler');
const router = express.Router(); const router = express.Router();
@@ -50,8 +51,9 @@ router.post(
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
try { try {
const { rows } = await query( const { rows } = await query(
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id', `INSERT INTO users (email, password_hash, name, role)
[email.trim().toLowerCase(), hash, name.trim()] VALUES ($1, $2, $3, $4) RETURNING id`,
[email.trim().toLowerCase(), hash, name.trim(), ROLES.CUSTOMER]
); );
req.session.userId = rows[0].id; req.session.userId = rows[0].id;
res.redirect('/'); res.redirect('/');
@@ -100,6 +102,9 @@ router.post(
} }
req.session.userId = user.id; req.session.userId = user.id;
if (user.role === ROLES.ADMIN && (next === '/' || next === '/account')) {
return res.redirect('/admin');
}
res.redirect(next.startsWith('/') ? next : '/'); res.redirect(next.startsWith('/') ? next : '/');
}) })
); );
@@ -115,7 +120,7 @@ router.get(
requireAuth, requireAuth,
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { rows } = await query( const { rows } = await query(
'SELECT id, email, name, created_at FROM users WHERE id = $1', 'SELECT id, email, name, role, created_at FROM users WHERE id = $1',
[req.session.userId] [req.session.userId]
); );
const user = rows[0]; const user = rows[0];
+29
View File
@@ -0,0 +1,29 @@
const bcrypt = require('bcryptjs');
const { query } = require('./db');
const { ROLES } = require('./constants/roles');
async function seedAdmin() {
const email = (process.env.ADMIN_EMAIL || 'admin@site.com').trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD || 'admin';
const name = process.env.ADMIN_NAME || 'Администратор';
const { rows } = await query('SELECT id, role FROM users WHERE email = $1', [email]);
if (!rows[0]) {
const hash = bcrypt.hashSync(password, 10);
await query(
`INSERT INTO users (email, password_hash, name, role)
VALUES ($1, $2, $3, $4)`,
[email, hash, name, ROLES.ADMIN]
);
console.log('Создан администратор:', email);
return;
}
if (rows[0].role !== ROLES.ADMIN) {
await query('UPDATE users SET role = $1 WHERE id = $2', [ROLES.ADMIN, rows[0].id]);
console.log('Пользователю назначена роль admin:', email);
}
}
module.exports = { seedAdmin };
-1
View File
@@ -3,7 +3,6 @@ const { query } = require('./db');
async function runSeed() { async function runSeed() {
const { rows } = await query('SELECT COUNT(*)::int AS n FROM products'); const { rows } = await query('SELECT COUNT(*)::int AS n FROM products');
if (rows[0].n > 0) { if (rows[0].n > 0) {
console.log('База уже содержит товары, пропуск seed.');
return; return;
} }
+4
View File
@@ -5,10 +5,12 @@ const pgSession = require('connect-pg-simple')(session);
const { pool, initSchema, checkConnection } = require('./db'); const { pool, initSchema, checkConnection } = require('./db');
const { runSeed } = require('./seed'); const { runSeed } = require('./seed');
const { seedAdmin } = require('./seed-admin');
const { loadUser } = require('./middleware/auth'); 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 adminRoutes = require('./routes/admin');
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
@@ -18,6 +20,7 @@ async function start() {
await checkConnection(); await checkConnection();
await initSchema(); await initSchema();
await runSeed(); await runSeed();
await seedAdmin();
const app = express(); const app = express();
@@ -54,6 +57,7 @@ async function start() {
app.use(loadUser); app.use(loadUser);
app.use('/', shopRoutes); app.use('/', shopRoutes);
app.use('/', authRoutes); app.use('/', authRoutes);
app.use('/admin', adminRoutes);
app.use((req, res) => { app.use((req, res) => {
res.status(404).render('error', { res.status(404).render('error', {
+2
View File
@@ -5,6 +5,8 @@
<div class="card account-card"> <div class="card account-card">
<p><strong><%= user.name %></strong></p> <p><strong><%= user.name %></strong></p>
<p class="muted"><%= user.email %></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> <p class="muted">С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %></p>
<div class="account-actions"> <div class="account-actions">
<a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a> <a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a>
+62
View File
@@ -0,0 +1,62 @@
<%- include('../partials/layout-start') %>
<div class="admin-header">
<h1>Админ-панель</h1>
<nav class="admin-nav">
<a href="/admin" class="admin-nav__link admin-nav__link--active">Обзор</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-card__label">Пользователи</span>
<strong class="stat-card__value"><%= stats.users %></strong>
</div>
<div class="stat-card">
<span class="stat-card__label">Товары</span>
<strong class="stat-card__value"><%= stats.products %></strong>
</div>
<div class="stat-card">
<span class="stat-card__label">Заказы</span>
<strong class="stat-card__value"><%= stats.orders %></strong>
</div>
<div class="stat-card">
<span class="stat-card__label">Выручка</span>
<strong class="stat-card__value"><%= formatPrice(stats.revenue) %></strong>
</div>
</div>
<h2>Последние заказы</h2>
<% if (!recentOrders.length) { %>
<p class="muted">Заказов пока нет.</p>
<% } else { %>
<table class="cart-table">
<thead>
<tr>
<th>№</th>
<th>Клиент</th>
<th>Статус</th>
<th>Сумма</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
<% recentOrders.forEach(o => { %>
<tr>
<td>#<%= o.id %></td>
<td><%= o.customer_name %><br><span class="muted"><%= o.user_email %></span></td>
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
<td><%= formatPrice(o.total_cents) %></td>
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
<%- include('../partials/layout-end') %>
+53
View File
@@ -0,0 +1,53 @@
<%- include('../partials/layout-start') %>
<div class="admin-header">
<h1>Заказы</h1>
<nav class="admin-nav">
<a href="/admin" class="admin-nav__link">Обзор</a>
<a href="/admin/orders" class="admin-nav__link admin-nav__link--active">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
<table class="cart-table">
<thead>
<tr>
<th>№</th>
<th>Клиент</th>
<th>Статус</th>
<th>Сумма</th>
<th>Дата</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<% orders.forEach(o => { %>
<tr>
<td>#<%= o.id %></td>
<td>
<%= o.customer_name %><br>
<span class="muted"><%= o.customer_email %></span>
</td>
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
<td><%= formatPrice(o.total_cents) %></td>
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
<td>
<form method="post" action="/admin/orders/<%= o.id %>/status" class="inline-form admin-status-form">
<select name="status" class="input input--sm">
<% ['pending','paid','shipped','cancelled'].forEach(s => { %>
<option value="<%= s %>" <%= o.status === s ? 'selected' : '' %>><%= statusLabels[s] %></option>
<% }) %>
</select>
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<%- include('../partials/layout-end') %>
+39
View File
@@ -0,0 +1,39 @@
<%- include('../partials/layout-start') %>
<div class="admin-header">
<h1>Товары</h1>
<nav class="admin-nav">
<a href="/admin" class="admin-nav__link">Обзор</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
<a href="/admin/products" class="admin-nav__link admin-nav__link--active">Товары</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
<table class="cart-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Категория</th>
<th>Цена</th>
<th>Остаток</th>
<th></th>
</tr>
</thead>
<tbody>
<% products.forEach(p => { %>
<tr>
<td><%= p.id %></td>
<td><%= p.name %></td>
<td><%= p.category_name || '—' %></td>
<td><%= formatPrice(p.price_cents) %></td>
<td><%= p.stock %></td>
<td><a href="/product/<%= p.slug %>">На сайте</a></td>
</tr>
<% }) %>
</tbody>
</table>
<%- include('../partials/layout-end') %>
+39
View File
@@ -0,0 +1,39 @@
<%- include('../partials/layout-start') %>
<div class="admin-header">
<h1>Пользователи</h1>
<nav class="admin-nav">
<a href="/admin" class="admin-nav__link">Обзор</a>
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
<a href="/admin/users" class="admin-nav__link admin-nav__link--active">Пользователи</a>
<a href="/admin/products" class="admin-nav__link">Товары</a>
<a href="/" class="admin-nav__link">В магазин</a>
</nav>
</div>
<table class="cart-table">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Email</th>
<th>Роль</th>
<th>Регистрация</th>
</tr>
</thead>
<tbody>
<% users.forEach(u => { %>
<tr>
<td><%= u.id %></td>
<td><%= u.name %></td>
<td><%= u.email %></td>
<td>
<span class="role-badge role-badge--<%= u.role %>"><%= roleLabels[u.role] || u.role %></span>
</td>
<td><%= new Date(u.created_at).toLocaleString('ru-RU') %></td>
</tr>
<% }) %>
</tbody>
</table>
<%- include('../partials/layout-end') %>
+3
View File
@@ -23,6 +23,9 @@
<% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %> <% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %>
</a> </a>
<% if (user) { %> <% if (user) { %>
<% if (typeof isAdmin !== 'undefined' && isAdmin) { %>
<a href="/admin" class="nav__link nav__admin">Админ</a>
<% } %>
<a href="/account" class="nav__link"><%= user.name %></a> <a href="/account" class="nav__link"><%= user.name %></a>
<form action="/logout" method="post" class="inline-form"> <form action="/logout" method="post" class="inline-form">
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button> <button type="submit" class="btn btn--ghost btn--sm">Выйти</button>