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);