feat: роли customer/admin, админ-панель, admin@site.com
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,11 @@
|
||||
const ROLES = {
|
||||
CUSTOMER: 'customer',
|
||||
ADMIN: 'admin',
|
||||
};
|
||||
|
||||
const ROLE_LABELS = {
|
||||
customer: 'Клиент',
|
||||
admin: 'Администратор',
|
||||
};
|
||||
|
||||
module.exports = { ROLES, ROLE_LABELS };
|
||||
@@ -26,10 +26,13 @@ async function query(text, params) {
|
||||
}
|
||||
|
||||
async function initSchema() {
|
||||
const schemaPath = path.join(__dirname, '..', 'postgres', 'init', '01_schema.sql');
|
||||
const sql = fs.readFileSync(schemaPath, 'utf8');
|
||||
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() {
|
||||
await pool.query('SELECT 1');
|
||||
|
||||
+19
-2
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<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>
|
||||
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -23,6 +23,9 @@
|
||||
<% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %>
|
||||
</a>
|
||||
<% 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>
|
||||
<form action="/logout" method="post" class="inline-form">
|
||||
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
|
||||
|
||||
Reference in New Issue
Block a user