diff --git a/auth.js b/auth.js
index 5ba10b1..a75bb87 100644
--- a/auth.js
+++ b/auth.js
@@ -1,4 +1,5 @@
const session = require('express-session');
+const bcrypt = require('bcrypt');
const { db } = require('./db');
const sessionMiddleware = session({
@@ -8,23 +9,30 @@ const sessionMiddleware = session({
cookie: { secure: false, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 }
});
-function ensureAdmin() {
+/**
+ * Гарантирует существование администратора, заданного переменными окружения
+ * (ADMIN_LOGIN / ADMIN_PASSWORD). Пароль всегда хэшируется.
+ */
+async function ensureAdmin() {
const login = process.env.ADMIN_LOGIN || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin';
+ const saltRounds = 10;
- // Таблица admins уже создана в db.js, можно сразу искать
const admin = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
+ const hashed = await bcrypt.hash(password, saltRounds);
+
if (admin) {
- // Обновить пароль при каждом старте
- db.prepare('UPDATE admins SET password = ? WHERE login = ?').run(password, login);
- console.log(`Пароль администратора "${login}" обновлён`);
+ db.prepare('UPDATE admins SET password = ? WHERE login = ?').run(hashed, login);
+ console.log(`Пароль администратора "${login}" обновлён (хэширован)`);
} else {
- // Создать запись
- db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)').run(login, password);
- console.log(`Администратор "${login}" создан`);
+ db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)').run(login, hashed);
+ console.log(`Администратор "${login}" создан (хэширован)`);
}
}
+/**
+ * Middleware для проверки прав администратора.
+ */
function requireAdmin(req, res, next) {
if (req.session && req.session.isAdmin) {
return next();
diff --git a/mailer.js b/mailer.js
index 58479c0..91743be 100644
--- a/mailer.js
+++ b/mailer.js
@@ -1,20 +1,69 @@
const nodemailer = require('nodemailer');
-const transporter = nodemailer.createTransport({
- host: process.env.SMTP_HOST,
- port: parseInt(process.env.SMTP_PORT) || 587,
- secure: false,
- auth: {
- user: process.env.SMTP_USER,
- pass: process.env.SMTP_PASS
+let transporter = null;
+let emailEnabled = true; // по умолчанию включено, при ошибке станет false
+let verificationDone = false; // проверка выполнена хотя бы раз
+
+function createTransporter() {
+ if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) {
+ console.warn('[MAILER] SMTP не настроен (отсутствуют переменные окружения). Почтовые уведомления отключены.');
+ return null;
}
-});
+ return nodemailer.createTransport({
+ host: process.env.SMTP_HOST,
+ port: parseInt(process.env.SMTP_PORT) || 587,
+ secure: false,
+ auth: {
+ user: process.env.SMTP_USER,
+ pass: process.env.SMTP_PASS
+ }
+ });
+}
+
+// Функция проверки соединения с почтовым сервером
+async function verifyEmailConnection() {
+ if (verificationDone) return emailEnabled;
+
+ transporter = createTransporter();
+ if (!transporter) {
+ emailEnabled = false;
+ verificationDone = true;
+ return false;
+ }
+
+ try {
+ await transporter.verify();
+ console.log('[MAILER] SMTP подключение успешно, уведомления активны.');
+ emailEnabled = true;
+ } catch (err) {
+ console.error('[MAILER] Ошибка подключения к SMTP:', err.message);
+ console.warn('[MAILER] Почтовые уведомления будут отключены до следующего перезапуска.');
+ emailEnabled = false;
+ transporter = null; // сбросим, чтобы не использовать нерабочий
+ } finally {
+ verificationDone = true;
+ }
+ return emailEnabled;
+}
async function sendNotification(emails, subject, text) {
+ if (!emailEnabled) {
+ // Молча пропускаем, т.к. проверка уже показала неработоспособность
+ return;
+ }
if (!emails) return;
const recipients = emails.split(',').map(e => e.trim()).filter(Boolean);
if (recipients.length === 0) return;
+ // Если транспортер ещё не создан (не было вызова verify), создадим его
+ if (!transporter) {
+ transporter = createTransporter();
+ if (!transporter) {
+ emailEnabled = false;
+ return;
+ }
+ }
+
try {
await transporter.sendMail({
from: process.env.SMTP_USER,
@@ -25,6 +74,11 @@ async function sendNotification(emails, subject, text) {
console.log(`Уведомление отправлено на ${recipients.join(', ')}`);
} catch (err) {
console.error('Ошибка отправки письма:', err);
+ // Если при отправке возникла ошибка, считаем почту нерабочей до перезапуска
+ if (emailEnabled) {
+ console.warn('[MAILER] Отключение почтовых уведомлений из-за ошибки отправки.');
+ emailEnabled = false;
+ }
}
}
@@ -49,4 +103,4 @@ async function notifyBookingUpdate(booking, changes) {
await sendNotification(process.env.EMAIL_NOTIFY_UPDATE, subject, text);
}
-module.exports = { notifyNewBooking, notifyBookingUpdate };
\ No newline at end of file
+module.exports = { verifyEmailConnection, notifyNewBooking, notifyBookingUpdate };
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 4740772..264261c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,7 @@
"": {
"dependencies": {
"axios": "^1.16.0",
+ "bcrypt": "^6.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
@@ -87,6 +88,20 @@
],
"license": "MIT"
},
+ "node_modules/bcrypt": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
+ "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^8.3.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/better-sqlite3": {
"version": "12.9.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
@@ -1067,6 +1082,17 @@
"node": "^20.17.0 || >=22.9.0"
}
},
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
diff --git a/package.json b/package.json
index 46faa10..e555c66 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
{
"dependencies": {
"axios": "^1.16.0",
+ "bcrypt": "^6.0.0",
"better-sqlite3": "^12.9.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
diff --git a/public/admin.html b/public/admin.html
index 92d99a7..970d653 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -1,5 +1,5 @@
-
+
@@ -12,6 +12,40 @@
Сервис:
+
+
+
+ Управление администраторами
+
+
+ | ID | Логин | Действия |
+
+
+
+
+ Добавить нового администратора
+
+
+
+
+
@@ -21,6 +55,7 @@
if (!data.isAdmin) window.location.href = '/login.html';
});
+ // --- Синхронизация ---
document.getElementById('syncBtn').addEventListener('click', async () => {
const status = document.getElementById('syncStatus');
status.textContent = 'Синхронизация...';
@@ -28,6 +63,106 @@
if (res.ok) status.textContent = 'Синхронизация завершена';
else status.textContent = 'Ошибка синхронизации';
});
+
+ // --- Управление администраторами ---
+ const adminsTbody = document.querySelector('#adminsTable tbody');
+ const addForm = document.getElementById('addAdminForm');
+ const modal = document.getElementById('editPasswordModal');
+ const closeModalBtn = document.getElementById('closePasswordModal');
+
+ async function loadAdmins() {
+ const res = await fetch('/api/admins');
+ const admins = await res.json();
+ adminsTbody.innerHTML = '';
+ for (const admin of admins) {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+ ${admin.id} |
+ ${escapeHtml(admin.login)} |
+
+
+
+ |
+ `;
+ adminsTbody.appendChild(tr);
+ }
+ // Привязываем обработчики к динамическим кнопкам
+ document.querySelectorAll('.changePasswordBtn').forEach(btn => {
+ btn.addEventListener('click', () => {
+ document.getElementById('editAdminId').value = btn.dataset.id;
+ document.getElementById('editPassword').value = '';
+ modal.style.display = 'flex';
+ });
+ });
+ document.querySelectorAll('.deleteAdminBtn').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const id = btn.dataset.id;
+ if (confirm('Удалить администратора? Вы не сможете удалить самого себя и последнего админа.')) {
+ const res = await fetch(`/api/admins/${id}`, { method: 'DELETE' });
+ if (res.ok) {
+ loadAdmins();
+ } else {
+ const err = await res.json();
+ alert('Ошибка: ' + (err.error || 'неизвестная'));
+ }
+ }
+ });
+ });
+ }
+
+ addForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const login = document.getElementById('newLogin').value.trim();
+ const password = document.getElementById('newPassword').value;
+ const res = await fetch('/api/admins', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ login, password })
+ });
+ if (res.ok) {
+ alert('Администратор добавлен');
+ addForm.reset();
+ loadAdmins();
+ } else {
+ const err = await res.json();
+ alert('Ошибка: ' + (err.error || 'неизвестная'));
+ }
+ });
+
+ document.getElementById('editPasswordForm').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const id = document.getElementById('editAdminId').value;
+ const password = document.getElementById('editPassword').value;
+ const res = await fetch(`/api/admins/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ password })
+ });
+ if (res.ok) {
+ alert('Пароль изменён');
+ modal.style.display = 'none';
+ loadAdmins();
+ } else {
+ const err = await res.json();
+ alert('Ошибка: ' + (err.error || 'неизвестная'));
+ }
+ });
+
+ closeModalBtn.addEventListener('click', () => {
+ modal.style.display = 'none';
+ });
+
+ function escapeHtml(str) {
+ if (!str) return '';
+ return str.replace(/[&<>]/g, function(m) {
+ if (m === '&') return '&';
+ if (m === '<') return '<';
+ if (m === '>') return '>';
+ return m;
+ });
+ }
+
+ loadAdmins();
\ No newline at end of file
diff --git a/public/style.css b/public/style.css
index eb3a2e5..eea9a41 100644
--- a/public/style.css
+++ b/public/style.css
@@ -20,4 +20,7 @@ label { font-weight: 500; }
@media (max-width: 600px) {
header { padding: 0.5rem 1rem; }
nav { flex-direction: column; }
-}
\ No newline at end of file
+}
+hr { margin: 2rem 0; border: none; border-top: 1px solid #ddd; }
+#adminsTable button { margin-right: 0.5rem; }
+#addAdminForm { margin-top: 1rem; background: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); max-width: 400px; }
\ No newline at end of file
diff --git a/server.js b/server.js
index a1b6d52..6f10b33 100644
--- a/server.js
+++ b/server.js
@@ -1,11 +1,13 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
+const bcrypt = require('bcrypt');
+const cron = require('node-cron');
+
const { db, normalizePhone, logAction } = require('./db');
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth');
const { syncBookings } = require('./sync');
-const { notifyBookingUpdate } = require('./mailer');
-const cron = require('node-cron');
+const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -17,17 +19,26 @@ app.use(sessionMiddleware);
// Статика из public
app.use(express.static(path.join(__dirname, 'public')));
-// Инициализация администратора
-ensureAdmin();
+// === Инициализация ===
+// Асинхронное создание/обновление администратора из .env
+(async () => {
+ await ensureAdmin();
+})();
+
+// Проверка работоспособности SMTP при старте (отключит уведомления при ошибке)
+verifyEmailConnection().catch(err => {
+ console.error('Ошибка при проверке SMTP:', err);
+});
// === API ===
-// Вход администратора
-app.post('/api/login', (req, res) => {
+// Вход администратора (с хэшированием и сохранением adminId)
+app.post('/api/login', async (req, res) => {
const { login, password } = req.body;
- const admin = db.prepare('SELECT * FROM admins WHERE login = ? AND password = ?').get(login, password);
- if (admin) {
+ const admin = db.prepare('SELECT id, login, password FROM admins WHERE login = ?').get(login);
+ if (admin && await bcrypt.compare(password, admin.password)) {
req.session.isAdmin = true;
+ req.session.adminId = admin.id;
res.json({ success: true });
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
@@ -45,7 +56,71 @@ app.get('/api/me', (req, res) => {
res.json({ isAdmin: !!req.session.isAdmin });
});
-// Список заявок (с фильтрами, только для админа)
+// --- Управление администраторами (CRUD) ---
+
+// Получить список всех администраторов (только id и login)
+app.get('/api/admins', requireAdmin, (req, res) => {
+ const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all();
+ res.json(admins);
+});
+
+// Добавить нового администратора
+app.post('/api/admins', requireAdmin, async (req, res) => {
+ const { login, password } = req.body;
+ if (!login || !password) {
+ return res.status(400).json({ error: 'Логин и пароль обязательны' });
+ }
+ const existing = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
+ if (existing) {
+ return res.status(409).json({ error: 'Администратор с таким логином уже существует' });
+ }
+ const saltRounds = 10;
+ const hashed = await bcrypt.hash(password, saltRounds);
+ const stmt = db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)');
+ const info = stmt.run(login, hashed);
+ res.status(201).json({ id: info.lastInsertRowid, login });
+});
+
+// Изменить пароль администратора
+app.put('/api/admins/:id', requireAdmin, async (req, res) => {
+ const { id } = req.params;
+ const { password } = req.body;
+ if (!password) {
+ return res.status(400).json({ error: 'Новый пароль обязателен' });
+ }
+ const admin = db.prepare('SELECT id, login FROM admins WHERE id = ?').get(id);
+ if (!admin) {
+ return res.status(404).json({ error: 'Администратор не найден' });
+ }
+ const saltRounds = 10;
+ const hashed = await bcrypt.hash(password, saltRounds);
+ db.prepare('UPDATE admins SET password = ? WHERE id = ?').run(hashed, id);
+ res.json({ id: admin.id, login: admin.login });
+});
+
+// Удалить администратора
+app.delete('/api/admins/:id', requireAdmin, (req, res) => {
+ const { id } = req.params;
+ // Проверка: нельзя удалить последнего администратора
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM admins').get().cnt;
+ if (count <= 1) {
+ return res.status(400).json({ error: 'Нельзя удалить последнего администратора' });
+ }
+ // Нельзя удалить самого себя
+ if (req.session.adminId && req.session.adminId == id) {
+ return res.status(400).json({ error: 'Нельзя удалить свою учётную запись' });
+ }
+ const stmt = db.prepare('DELETE FROM admins WHERE id = ?');
+ const result = stmt.run(id);
+ if (result.changes === 0) {
+ return res.status(404).json({ error: 'Администратор не найден' });
+ }
+ res.json({ success: true });
+});
+
+// --- Заявки (bookings) ---
+
+// Список заявок с фильтрами
app.get('/api/bookings', requireAdmin, (req, res) => {
const { status, search, client_id } = req.query;
let query = 'SELECT * FROM bookings WHERE 1=1';
@@ -75,10 +150,10 @@ app.get('/api/bookings/:id', requireAdmin, (req, res) => {
res.json(booking);
});
-// Обновление заявки (статус, комментарии, перепривязка клиента)
+// Обновление заявки (статус, комментарий, перепривязка клиента)
app.put('/api/bookings/:id', requireAdmin, (req, res) => {
const { id } = req.params;
- const { status, comments, phone } = req.body; // phone для перепривязки карточки
+ const { status, comments, phone } = req.body;
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
@@ -125,7 +200,9 @@ app.put('/api/bookings/:id', requireAdmin, (req, res) => {
res.json(updatedBooking);
});
-// Список карточек клиентов
+// --- Карточки клиентов ---
+
+// Список клиентов
app.get('/api/clients', requireAdmin, (req, res) => {
const { search } = req.query;
let query = 'SELECT * FROM users WHERE 1=1';
@@ -139,7 +216,7 @@ app.get('/api/clients', requireAdmin, (req, res) => {
res.json(clients);
});
-// Профиль клиента с его заявками (сортировка: сначала по дате убыванию, потом по статусу в алфавитном порядке)
+// Профиль клиента с его заявками
app.get('/api/clients/:id', requireAdmin, (req, res) => {
const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!client) return res.status(404).json({ error: 'Клиент не найден' });
@@ -153,13 +230,15 @@ app.get('/api/clients/:id', requireAdmin, (req, res) => {
res.json({ client, bookings });
});
+// --- Синхронизация ---
+
// Ручной запуск синхронизации
app.post('/api/admin/sync', requireAdmin, async (req, res) => {
await syncBookings();
res.json({ success: true, message: 'Синхронизация запущена' });
});
-// Планировщик синхронизации каждые 5 минут (опционально)
+// Планировщик синхронизации каждые 5 минут
cron.schedule('*/5 * * * *', () => {
console.log('Автосинхронизация...');
syncBookings().catch(console.error);