diff --git a/.env.example b/.env.example index 68707ea..220bbaa 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ NODE_ENV=production TRUST_PROXY=1 SESSION_SECRET=change-me-to-a-long-random-string +# Первый администратор (создаётся при старте, если email не занят) +ADMIN_EMAIL=admin@site.com +ADMIN_PASSWORD=admin +ADMIN_NAME=Администратор + # PostgreSQL 17 (одна строка или отдельные переменные) DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop # PGHOST=127.0.0.1 diff --git a/postgres/init/01_schema.sql b/postgres/init/01_schema.sql index 82a8408..b3eb625 100644 --- a/postgres/init/01_schema.sql +++ b/postgres/init/01_schema.sql @@ -5,9 +5,13 @@ CREATE TABLE IF NOT EXISTS users ( email TEXT NOT NULL UNIQUE, password_hash 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() ); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); + CREATE TABLE IF NOT EXISTS categories ( id SERIAL PRIMARY KEY, slug TEXT NOT NULL UNIQUE, diff --git a/postgres/init/02_roles.sql b/postgres/init/02_roles.sql new file mode 100644 index 0000000..fc00073 --- /dev/null +++ b/postgres/init/02_roles.sql @@ -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); diff --git a/src/constants/roles.js b/src/constants/roles.js new file mode 100644 index 0000000..cd5bf73 --- /dev/null +++ b/src/constants/roles.js @@ -0,0 +1,11 @@ +const ROLES = { + CUSTOMER: 'customer', + ADMIN: 'admin', +}; + +const ROLE_LABELS = { + customer: 'Клиент', + admin: 'Администратор', +}; + +module.exports = { ROLES, ROLE_LABELS }; diff --git a/src/db.js b/src/db.js index 0b1443d..ee26cf5 100644 --- a/src/db.js +++ b/src/db.js @@ -26,9 +26,12 @@ async function query(text, params) { } async function initSchema() { - const schemaPath = path.join(__dirname, '..', 'postgres', 'init', '01_schema.sql'); - const sql = fs.readFileSync(schemaPath, 'utf8'); - await pool.query(sql); + const initDir = path.join(__dirname, '..', 'postgres', 'init'); + const files = fs.readdirSync(initDir).filter((f) => f.endsWith('.sql')).sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(initDir, file), 'utf8'); + await pool.query(sql); + } } async function checkConnection() { diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 232aad7..ba0be75 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,5 +1,6 @@ const { query } = require('../db'); const { asyncHandler } = require('../utils/asyncHandler'); +const { ROLES } = require('../constants/roles'); function requireAuth(req, res, next) { if (!req.session.userId) { @@ -9,17 +10,33 @@ function requireAuth(req, res, 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) => { if (req.session.userId) { 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] ); res.locals.user = rows[0] || null; } else { res.locals.user = null; } + res.locals.isAdmin = res.locals.user?.role === ROLES.ADMIN; next(); }); -module.exports = { requireAuth, loadUser }; +module.exports = { requireAuth, requireAdmin, loadUser }; diff --git a/src/public/css/style.css b/src/public/css/style.css index 21835a1..804aafb 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -579,3 +579,95 @@ a:hover { display: inline; 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; +} diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..7b55bcd --- /dev/null +++ b/src/routes/admin.js @@ -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; diff --git a/src/routes/auth.js b/src/routes/auth.js index d9337ea..ded0d10 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -3,6 +3,7 @@ const bcrypt = require('bcryptjs'); const { query, formatPrice } = require('../db'); const { getCart, cartCount } = require('../cart'); const { requireAuth } = require('../middleware/auth'); +const { ROLES } = require('../constants/roles'); const { asyncHandler } = require('../utils/asyncHandler'); const router = express.Router(); @@ -50,8 +51,9 @@ router.post( const hash = bcrypt.hashSync(password, 10); try { const { rows } = await query( - 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id', - [email.trim().toLowerCase(), hash, name.trim()] + `INSERT INTO users (email, password_hash, name, role) + VALUES ($1, $2, $3, $4) RETURNING id`, + [email.trim().toLowerCase(), hash, name.trim(), ROLES.CUSTOMER] ); req.session.userId = rows[0].id; res.redirect('/'); @@ -100,6 +102,9 @@ router.post( } req.session.userId = user.id; + if (user.role === ROLES.ADMIN && (next === '/' || next === '/account')) { + return res.redirect('/admin'); + } res.redirect(next.startsWith('/') ? next : '/'); }) ); @@ -115,7 +120,7 @@ router.get( requireAuth, asyncHandler(async (req, res) => { 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] ); const user = rows[0]; diff --git a/src/seed-admin.js b/src/seed-admin.js new file mode 100644 index 0000000..f2c47ff --- /dev/null +++ b/src/seed-admin.js @@ -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 }; diff --git a/src/seed.js b/src/seed.js index 39e67c8..1e2b96d 100644 --- a/src/seed.js +++ b/src/seed.js @@ -3,7 +3,6 @@ const { query } = require('./db'); async function runSeed() { const { rows } = await query('SELECT COUNT(*)::int AS n FROM products'); if (rows[0].n > 0) { - console.log('База уже содержит товары, пропуск seed.'); return; } diff --git a/src/server.js b/src/server.js index 7624e81..51e40e2 100644 --- a/src/server.js +++ b/src/server.js @@ -5,10 +5,12 @@ const pgSession = require('connect-pg-simple')(session); const { pool, initSchema, checkConnection } = require('./db'); const { runSeed } = require('./seed'); +const { seedAdmin } = require('./seed-admin'); const { loadUser } = require('./middleware/auth'); const healthRoutes = require('./routes/health'); const shopRoutes = require('./routes/shop'); const authRoutes = require('./routes/auth'); +const adminRoutes = require('./routes/admin'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || '0.0.0.0'; @@ -18,6 +20,7 @@ async function start() { await checkConnection(); await initSchema(); await runSeed(); + await seedAdmin(); const app = express(); @@ -54,6 +57,7 @@ async function start() { app.use(loadUser); app.use('/', shopRoutes); app.use('/', authRoutes); + app.use('/admin', adminRoutes); app.use((req, res) => { res.status(404).render('error', { diff --git a/src/views/account.ejs b/src/views/account.ejs index a0de022..38606cd 100644 --- a/src/views/account.ejs +++ b/src/views/account.ejs @@ -5,6 +5,8 @@

<%= user.name %>

<%= user.email %>

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

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

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

Мои заказы (<%= orderCount %>) diff --git a/src/views/admin/dashboard.ejs b/src/views/admin/dashboard.ejs new file mode 100644 index 0000000..998c00f --- /dev/null +++ b/src/views/admin/dashboard.ejs @@ -0,0 +1,62 @@ +<%- include('../partials/layout-start') %> + + + +
+
+ Пользователи + <%= stats.users %> +
+
+ Товары + <%= stats.products %> +
+
+ Заказы + <%= stats.orders %> +
+
+ Выручка + <%= formatPrice(stats.revenue) %> +
+
+ +

Последние заказы

+<% if (!recentOrders.length) { %> +

Заказов пока нет.

+<% } else { %> + + + + + + + + + + + + <% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %> + <% recentOrders.forEach(o => { %> + + + + + + + + <% }) %> + +
КлиентСтатусСуммаДата
#<%= o.id %><%= o.customer_name %>
<%= o.user_email %>
<%= statusLabels[o.status] || o.status %><%= formatPrice(o.total_cents) %><%= new Date(o.created_at).toLocaleString('ru-RU') %>
+<% } %> + +<%- include('../partials/layout-end') %> diff --git a/src/views/admin/orders.ejs b/src/views/admin/orders.ejs new file mode 100644 index 0000000..bc93040 --- /dev/null +++ b/src/views/admin/orders.ejs @@ -0,0 +1,53 @@ +<%- include('../partials/layout-start') %> + + + +<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %> + + + + + + + + + + + + + + <% orders.forEach(o => { %> + + + + + + + + + <% }) %> + +
КлиентСтатусСуммаДатаДействие
#<%= o.id %> + <%= o.customer_name %>
+ <%= o.customer_email %> +
<%= statusLabels[o.status] || o.status %><%= formatPrice(o.total_cents) %><%= new Date(o.created_at).toLocaleString('ru-RU') %> +
+ + +
+
+ +<%- include('../partials/layout-end') %> diff --git a/src/views/admin/products.ejs b/src/views/admin/products.ejs new file mode 100644 index 0000000..97ed7a2 --- /dev/null +++ b/src/views/admin/products.ejs @@ -0,0 +1,39 @@ +<%- include('../partials/layout-start') %> + + + + + + + + + + + + + + + + <% products.forEach(p => { %> + + + + + + + + + <% }) %> + +
IDНазваниеКатегорияЦенаОстаток
<%= p.id %><%= p.name %><%= p.category_name || '—' %><%= formatPrice(p.price_cents) %><%= p.stock %>На сайте
+ +<%- include('../partials/layout-end') %> diff --git a/src/views/admin/users.ejs b/src/views/admin/users.ejs new file mode 100644 index 0000000..2076a71 --- /dev/null +++ b/src/views/admin/users.ejs @@ -0,0 +1,39 @@ +<%- include('../partials/layout-start') %> + + + + + + + + + + + + + + + <% users.forEach(u => { %> + + + + + + + + <% }) %> + +
IDИмяEmailРольРегистрация
<%= u.id %><%= u.name %><%= u.email %> + <%= roleLabels[u.role] || u.role %> + <%= new Date(u.created_at).toLocaleString('ru-RU') %>
+ +<%- include('../partials/layout-end') %> diff --git a/src/views/partials/layout-start.ejs b/src/views/partials/layout-start.ejs index 0679ea3..52a6880 100644 --- a/src/views/partials/layout-start.ejs +++ b/src/views/partials/layout-start.ejs @@ -23,6 +23,9 @@ <% if (cartCount > 0) { %><%= cartCount %><% } %> <% if (user) { %> + <% if (typeof isAdmin !== 'undefined' && isAdmin) { %> + Админ + <% } %> <%= user.name %>