This commit is contained in:
2026-05-04 21:09:42 +05:00
parent e772d5418b
commit e20188c869
9 changed files with 2390 additions and 449 deletions

41
auth.js
View File

@@ -1,10 +1,10 @@
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('./db');
const path = require('path');
// Директория для файла с сессиями (пусть будет data/)
const sessionDbPath = path.join(__dirname, 'data', 'sessions.sqlite');
const sessionMiddleware = session({
@@ -12,21 +12,39 @@ const sessionMiddleware = session({
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // для HTTPS нужно true, но у нас http
secure: false,
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 часа
sameSite: 'lax',
maxAge: 24 * 6 * 60 * 1000
},
store: new SQLiteStore({
db: 'sessions.sqlite', // имя файла в директории data
db: 'sessions.sqlite',
dir: path.join(__dirname, 'data'),
table: 'sessions' // имя таблицы
table: 'sessions'
})
});
/**
* Гарантирует существование администратора, заданного переменными окружения
* (ADMIN_LOGIN / ADMIN_PASSWORD). Пароль всегда хэшируется.
*/
function generateCsrfToken(req) {
if (!req.session.csrfToken) {
req.session.csrfToken = crypto.randomBytes(32).toString('hex');
}
return req.session.csrfToken;
}
function csrfProtection(req, res, next) {
if (req.method === 'GET') return next();
const token = req.headers['x-csrf-token'] || req.body._csrf;
if (!token || token !== req.session.csrfToken) {
return res.status(403).json({ error: 'CSRF token validation failed' });
}
next();
}
function injectCsrfToken(req, res, next) {
res.locals.csrfToken = generateCsrfToken(req);
next();
}
async function ensureAdmin() {
const login = process.env.ADMIN_LOGIN || 'admin';
const password = process.env.ADMIN_PASSWORD || 'admin';
@@ -44,9 +62,6 @@ async function ensureAdmin() {
}
}
/**
* Middleware для проверки прав администратора.
*/
function requireAdmin(req, res, next) {
if (req.session && req.session.isAdmin) {
return next();
@@ -54,4 +69,4 @@ function requireAdmin(req, res, next) {
res.status(401).json({ error: 'Не авторизован' });
}
module.exports = { sessionMiddleware, ensureAdmin, requireAdmin };
module.exports = { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin };

View File

@@ -4,128 +4,249 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Панель управления</title>
</head>
<body>
<header></header>
<div class="toast-container" id="toastContainer"></div>
<main>
<h1>Панель управления</h1>
<p>Сервис: <span id="serviceName"></span></p>
<button id="syncBtn">Запустить синхронизацию заявок</button>
<p id="syncStatus"></p>
<hr style="margin: 2rem 0;">
<div class="card" style="margin-bottom: 2rem;">
<h2 style="margin-top:0;">Синхронизация заявок</h2>
<p style="color: var(--text-muted); margin-bottom: 1rem; font-size: 0.9375rem;">Получение актуальных данных из внешней системы бронирования</p>
<button id="syncBtn" class="btn-success">
<span id="syncBtnText">Запустить синхронизацию</span>
<span id="syncBtnSpinner" class="loading-spinner" style="display:none;"></span>
</button>
<p id="syncStatus" style="margin-top: 0.75rem; color: var(--text-secondary); font-size: 0.875rem;"></p>
</div>
<h2>Управление администраторами</h2>
<h2>Администраторы</h2>
<div class="table-container" style="margin-bottom: 2rem;">
<table id="adminsTable">
<thead>
<tr><th>ID</th><th>Логин</th><th>Действия</th></tr>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
<tbody id="adminsBody"></tbody>
</table>
</div>
<h3>Добавить нового администратора</h3>
<h3>Добавить администратора</h3>
<div class="form-card">
<form id="addAdminForm">
<label>Логин</label>
<input type="text" id="newLogin" required>
<label>Пароль</label>
<input type="password" id="newPassword" required>
<div class="form-group">
<label for="newLogin">Логин</label>
<input type="text" id="newLogin" required placeholder="Минимум 3 символа" minlength="3">
</div>
<div class="form-group">
<label for="newPassword">Пароль</label>
<input type="password" id="newPassword" required placeholder="Минимум 6 символов" minlength="6">
</div>
<button type="submit">Добавить</button>
</form>
</div>
<!-- Модальное окно для смены пароля -->
<div id="editPasswordModal" class="modal" style="display:none;">
<div id="editPasswordModal" class="modal">
<div class="modal-content">
<h2>Смена пароля</h2>
<form id="editPasswordForm">
<input type="hidden" id="editAdminId">
<label>Новый пароль</label>
<input type="password" id="editPassword" required>
<button type="submit">Сохранить</button>
<button type="button" id="closePasswordModal">Отмена</button>
<div class="form-group">
<label>Администратор</label>
<input type="text" id="editAdminLogin" disabled style="opacity:0.6;">
</div>
<div class="form-group">
<label for="editPassword">Новый пароль</label>
<input type="password" id="editPassword" required placeholder="Минимум 6 символов" minlength="6">
</div>
<div style="display:flex;gap:0.75rem;margin-top:1.5rem;">
<button type="submit" style="flex:1;">Сохранить</button>
<button type="button" id="closePasswordModal" class="btn-secondary">Отмена</button>
</div>
</form>
</div>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
// Проверка авторизации
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function getCsrfToken() {
const stored = window.localStorage.getItem('csrfToken');
if (stored) return stored;
try {
const res = await fetch('/api/csrf-token');
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
return data.csrfToken;
} catch (e) {
return null;
}
}
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
// --- Синхронизация ---
document.getElementById('syncBtn').addEventListener('click', async () => {
const btn = document.getElementById('syncBtn');
const btnText = document.getElementById('syncBtnText');
const btnSpinner = document.getElementById('syncBtnSpinner');
const status = document.getElementById('syncStatus');
btn.disabled = true;
btnText.style.display = 'none';
btnSpinner.style.display = 'inline-block';
status.textContent = 'Синхронизация...';
const res = await fetch('/api/admin/sync', { method: 'POST' });
if (res.ok) status.textContent = 'Синхронизация завершена';
else status.textContent = 'Ошибка синхронизации';
try {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/admin/sync', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken }
});
if (res.ok) {
status.textContent = 'Синхронизация завершена успешно';
status.style.color = 'var(--success)';
showToast('Синхронизация завершена', 'success');
} else {
status.textContent = 'Ошибка синхронизации';
status.style.color = 'var(--danger)';
showToast('Ошибка синхронизации', 'error');
}
} catch (err) {
status.textContent = 'Ошибка соединения';
status.style.color = 'var(--danger)';
showToast('Ошибка соединения', 'error');
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
btnSpinner.style.display = 'none';
}
});
// --- Управление администраторами ---
const adminsTbody = document.querySelector('#adminsTable tbody');
const adminsTbody = document.getElementById('adminsBody');
const addForm = document.getElementById('addAdminForm');
const modal = document.getElementById('editPasswordModal');
const closeModalBtn = document.getElementById('closePasswordModal');
const passwordModal = document.getElementById('editPasswordModal');
const closePasswordBtn = document.getElementById('closePasswordModal');
async function loadAdmins() {
try {
const res = await fetch('/api/admins');
if (!res.ok) throw new Error('Ошибка загрузки');
const admins = await res.json();
adminsTbody.innerHTML = '';
for (const admin of admins) {
if (admins.length === 0) {
adminsTbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:2rem;">Нет администраторов</td></tr>';
return;
}
admins.forEach(admin => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${admin.id}</td>
<td>${escapeHtml(admin.login)}</td>
<td><strong>${escapeHtml(admin.login)}</strong></td>
<td>
<button class="changePasswordBtn" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button>
<button class="deleteAdminBtn" data-id="${admin.id}">Удалить</button>
<div class="action-buttons">
<button class="changePasswordBtn btn-secondary btn-sm" data-id="${admin.id}" data-login="${escapeHtml(admin.login)}">Сменить пароль</button>
<button class="deleteAdminBtn btn-danger btn-sm" data-id="${admin.id}">Удалить</button>
</div>
</td>
`;
adminsTbody.appendChild(tr);
}
// Привязываем обработчики к динамическим кнопкам
});
document.querySelectorAll('.changePasswordBtn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('editAdminId').value = btn.dataset.id;
document.getElementById('editAdminLogin').value = btn.dataset.login;
document.getElementById('editPassword').value = '';
modal.style.display = 'flex';
passwordModal.classList.add('active');
});
});
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 (confirm('Удалить администратора? Это действие нельзя отменить.')) {
try {
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/admins/${btn.dataset.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken }
});
if (res.ok) {
showToast('Администратор удалён', 'success');
loadAdmins();
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная'));
showToast(err.error || 'Ошибка удаления', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
}
}
});
});
} catch (err) {
showToast('Ошибка загрузки', 'error');
}
}
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const login = document.getElementById('newLogin').value.trim();
const password = document.getElementById('newPassword').value;
try {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/admins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ login, password })
});
if (res.ok) {
alert('Администратор добавлен');
showToast('Администратор добавлен', 'success');
addForm.reset();
loadAdmins();
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная'));
showToast(err.error || 'Ошибка', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
}
});
@@ -133,34 +254,30 @@
e.preventDefault();
const id = document.getElementById('editAdminId').value;
const password = document.getElementById('editPassword').value;
try {
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/admins/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ password })
});
if (res.ok) {
alert('Пароль изменён');
modal.style.display = 'none';
showToast('Пароль изменён', 'success');
passwordModal.classList.remove('active');
loadAdmins();
} else {
const err = await res.json();
alert('Ошибка: ' + (err.error || 'неизвестная'));
showToast(err.error || 'Ошибка', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
}
});
closeModalBtn.addEventListener('click', () => {
modal.style.display = 'none';
});
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>]/g, function(m) {
if (m === '&') return '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
closePasswordBtn.addEventListener('click', () => passwordModal.classList.remove('active'));
passwordModal.addEventListener('click', (e) => { if (e.target === passwordModal) passwordModal.classList.remove('active'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && passwordModal.classList.contains('active')) passwordModal.classList.remove('active'); });
loadAdmins();
</script>

View File

@@ -4,59 +4,267 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Карточка клиента</title>
<style>
.booking-cards { display: none; }
.booking-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 0.75rem;
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
animation: cardSlideIn 0.3s ease-out backwards;
}
.booking-card:hover { box-shadow: var(--shadow-md); }
.booking-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.booking-card-status-line {
margin-bottom: 0.5rem;
}
.booking-card-body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.booking-card-field {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.booking-card-label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
}
.booking-card-value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
.booking-card-comment {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-light);
font-size: 0.875rem;
color: var(--text-secondary);
}
@keyframes cardSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.table-container { display: none !important; }
.booking-cards { display: block; }
.booking-card-body {
grid-template-columns: 1fr;
gap: 0.5rem;
}
}
</style>
</head>
<body>
<header></header>
<div class="toast-container" id="toastContainer"></div>
<main>
<a href="clients.html" class="btn btn-secondary btn-sm" style="display:inline-flex;align-items:center;gap:0.375rem;margin-bottom:1.5rem;text-decoration:none;">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5"/></svg>
Назад
</a>
<h1>Профиль клиента</h1>
<div id="clientInfo"></div>
<h2>Заявки (сортировка: сначала новые, потом по статусу А-Я)</h2>
<div id="clientInfo" class="card" style="margin-bottom:2rem;"></div>
<h2>История заявок</h2>
<div class="table-container">
<table id="clientBookingsTable">
<thead>
<tr><th>ID внеш.</th><th>Имя</th><th>Даты</th><th>Статус</th><th>Комментарий</th></tr>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Даты</th>
<th>Статус</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody></tbody>
<tbody id="bookingsBody"></tbody>
</table>
<p><a href="clients.html">← Назад к списку</a></p>
</div>
<div id="bookingCards" class="booking-cards"></div>
<div id="emptyState" class="empty-state" style="display:none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div>
<h3>Заявок нет</h3>
<p>У этого клиента пока нет заявок</p>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getStatusClass(status) {
const map = {
'Новая': 'status-new',
'В работе': 'status-in-progress',
'Подтверждена': 'status-confirmed',
'Заселение': 'status-checkin',
'Завершена': 'status-completed',
'Отменена': 'status-cancelled'
};
return map[status] || 'status-new';
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const params = new URLSearchParams(window.location.search);
const clientId = params.get('id');
if (!clientId) {
document.body.innerHTML = '<h1>Не указан ID клиента</h1>';
document.querySelector('main').innerHTML = '<h1>Не указан ID клиента</h1><a href="clients.html" class="btn">Вернуться к списку</a>';
throw new Error('No id');
}
async function loadClient() {
const clientInfoEl = document.getElementById('clientInfo');
const tbody = document.getElementById('bookingsBody');
const bookingCards = document.getElementById('bookingCards');
const emptyState = document.getElementById('emptyState');
const tableContainer = document.querySelector('#clientBookingsTable').parentElement;
clientInfoEl.innerHTML = '<div class="loading-spinner"></div>';
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
bookingCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
try {
const res = await fetch(`/api/clients/${clientId}`);
if (!res.ok) {
document.getElementById('clientInfo').innerHTML = '<p>Клиент не найден</p>';
clientInfoEl.innerHTML = '<p style="color:var(--danger);">Клиент не найден</p>';
tbody.innerHTML = '';
bookingCards.innerHTML = '';
tableContainer.style.display = 'none';
return;
}
const data = await res.json();
document.getElementById('clientInfo').innerHTML = `
<p><strong>Телефон:</strong> ${data.client.phone}</p>
<p><strong>Имя:</strong> ${data.client.name || 'не указано'}</p>
clientInfoEl.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1.5rem;">
<div>
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Телефон</div>
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${escapeHtml(data.client.phone)}</div>
</div>
<div>
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Имя</div>
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${escapeHtml(data.client.name) || '<span style="color:var(--text-muted);">не указано</span>'}</div>
</div>
<div>
<div style="color:var(--text-muted);font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;font-weight:600;margin-bottom:0.25rem;">Заявок</div>
<div style="font-size:1.0625rem;font-weight:600;color:var(--text-primary);">${data.bookings.length}</div>
</div>
</div>
`;
const tbody = document.querySelector('#clientBookingsTable tbody');
tbody.innerHTML = '';
data.bookings.forEach(b => {
bookingCards.innerHTML = '';
if (data.bookings.length === 0) {
tableContainer.style.display = 'none';
emptyState.style.display = 'block';
return;
}
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
data.bookings.forEach((b, i) => {
const tr = document.createElement('tr');
tr.style.animationDelay = `${i * 0.03}s`;
tr.innerHTML = `
<td>${b.external_id || '-'}</td>
<td>${b.name}</td>
<td>${b.checkin_date} ${b.checkout_date}</td>
<td>${b.status}</td>
<td>${b.comments || ''}</td>
<td>${escapeHtml(b.external_id) || ''}</td>
<td>${escapeHtml(b.name)}</td>
<td>${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</td>
<td><span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span></td>
<td>${escapeHtml(b.comments) || '<span style="color:var(--text-muted);">—</span>'}</td>
`;
tbody.appendChild(tr);
const card = document.createElement('div');
card.className = 'booking-card';
card.style.animationDelay = `${i * 0.03}s`;
let html = `
<div class="booking-card-header">
<div>
<div style="font-weight:600;color:var(--text-primary);">${escapeHtml(b.name)}</div>
<div style="font-size:0.75rem;color:var(--text-muted);">ID: ${escapeHtml(b.external_id) || '—'}</div>
</div>
<span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span>
</div>
<div class="booking-card-body">
<div class="booking-card-field">
<span class="booking-card-label">Даты</span>
<span class="booking-card-value">${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</span>
</div>
</div>
`;
if (b.comments) {
html += `<div class="booking-card-comment">${escapeHtml(b.comments)}</div>`;
}
card.innerHTML = html;
bookingCards.appendChild(card);
});
} catch (err) {
showToast('Ошибка загрузки', 'error');
clientInfoEl.innerHTML = '';
tbody.innerHTML = '';
bookingCards.innerHTML = '';
}
}
loadClient();

View File

@@ -4,49 +4,192 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Клиенты</title>
<style>
.client-cards { display: none; }
.client-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 0.75rem;
box-shadow: var(--shadow-sm);
transition: all var(--transition-base);
animation: cardSlideIn 0.3s ease-out backwards;
}
.client-card:hover { box-shadow: var(--shadow-md); }
.client-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.client-card-name {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
}
.client-card-phone {
font-size: 0.875rem;
color: var(--text-muted);
}
.client-card-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.75rem;
border-top: 1px solid var(--border-light);
}
@media (max-width: 768px) {
.table-container { display: none !important; }
.client-cards { display: block; }
}
</style>
</head>
<body>
<header></header>
<div class="toast-container" id="toastContainer"></div>
<main>
<h1>Клиенты</h1>
<div class="filter-bar">
<div class="search-input-wrapper">
<input type="text" id="searchClient" placeholder="Поиск по имени или телефону">
<button id="searchClientBtn">Найти</button>
</div>
</div>
<div class="table-container">
<table id="clientsTable">
<thead>
<tr><th>ID</th><th>Телефон</th><th>Имя</th><th>Действия</th></tr>
<tr>
<th>ID</th>
<th>Телефон</th>
<th>Имя</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
<tbody id="clientsBody"></tbody>
</table>
</div>
<div id="clientCards" class="client-cards"></div>
<div id="emptyState" class="empty-state" style="display:none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/></svg>
</div>
<h3>Клиенты не найдены</h3>
<p>Попробуйте изменить параметры поиска</p>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const tbody = document.querySelector('#clientsTable tbody');
const tbody = document.getElementById('clientsBody');
const clientCards = document.getElementById('clientCards');
const emptyState = document.getElementById('emptyState');
const tableContainer = document.querySelector('.table-container');
async function loadClients() {
const search = document.getElementById('searchClient').value;
let url = '/api/clients?';
if (search) url += `search=${encodeURIComponent(search)}`;
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
clientCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Ошибка загрузки');
const clients = await res.json();
tbody.innerHTML = '';
clients.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${c.id}</td>
<td>${c.phone}</td>
<td>${c.name || '—'}</td>
<td><a href="client.html?id=${c.id}">Профиль</a></td>
`;
tbody.appendChild(tr);
});
clientCards.innerHTML = '';
if (clients.length === 0) {
tableContainer.style.display = 'none';
emptyState.style.display = 'block';
return;
}
document.getElementById('searchClientBtn').addEventListener('click', loadClients);
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
clients.forEach((c, i) => {
const tr = document.createElement('tr');
tr.style.animationDelay = `${i * 0.03}s`;
tr.innerHTML = `
<td>${c.id}</td>
<td>${escapeHtml(c.phone)}</td>
<td>${escapeHtml(c.name) || '<span style="color:var(--text-muted);">—</span>'}</td>
<td><a href="client.html?id=${c.id}" class="btn btn-secondary btn-sm" style="text-decoration:none;display:inline-flex;align-items:center;">Профиль</a></td>
`;
tbody.appendChild(tr);
const card = document.createElement('div');
card.className = 'client-card';
card.style.animationDelay = `${i * 0.03}s`;
card.innerHTML = `
<div class="client-card-header">
<div>
<div class="client-card-name">${escapeHtml(c.name) || 'Без имени'}</div>
<div class="client-card-phone">${escapeHtml(c.phone)}</div>
</div>
</div>
<div class="client-card-footer">
<a href="client.html?id=${c.id}" class="btn btn-secondary btn-sm" style="text-decoration:none;display:inline-flex;align-items:center;">Профиль</a>
</div>
`;
clientCards.appendChild(card);
});
} catch (err) {
showToast('Ошибка загрузки', 'error');
tbody.innerHTML = '';
clientCards.innerHTML = '';
}
}
let searchTimeout;
document.getElementById('searchClient').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadClients, 400);
});
document.getElementById('searchClient').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { clearTimeout(searchTimeout); loadClients(); }
});
loadClients();
</script>
</body>

View File

@@ -4,15 +4,41 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Заявки на заселение</title>
</head>
<body>
<header></header>
<div class="toast-container" id="toastContainer"></div>
<main>
<h1>Заявки на заселение</h1>
<div>
<label>Фильтр по статусу:
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-value" id="statTotal">0</div>
<div class="stat-label">Всего</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statNew">0</div>
<div class="stat-label">Новые</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statInProgress">0</div>
<div class="stat-label">В работе</div>
</div>
<div class="stat-card">
<div class="stat-value" id="statConfirmed">0</div>
<div class="stat-label">Подтверждены</div>
</div>
</div>
<div class="filter-bar">
<div class="search-input-wrapper">
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию">
</div>
<select id="statusFilter">
<option value="">Все</option>
<option value="">Все статусы</option>
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
<option value="Подтверждена">Подтверждена</option>
@@ -20,31 +46,42 @@
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
</label>
<input type="text" id="searchInput" placeholder="Поиск по имени, телефону или комментарию">
<button id="searchBtn">Найти</button>
</div>
<div id="tableContainer" class="table-container">
<table id="bookingsTable">
<thead>
<tr>
<th>ID внеш.</th>
<th>Имя</th>
<th>ID</th>
<th>Имя гостя</th>
<th>Телефон</th>
<th>Даты</th>
<th>Даты проживания</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
<tbody id="bookingsBody"></tbody>
</table>
</div>
<!-- Модальное окно редактирования -->
<div id="editModal" class="modal" style="display:none;">
<div id="bookingCards" class="booking-cards"></div>
<div id="emptyState" class="empty-state" style="display: none;">
<div class="empty-state-icon">
<svg width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/></svg>
</div>
<h3>Заявки не найдены</h3>
<p>Попробуйте изменить параметры поиска</p>
</div>
<div id="editModal" class="modal">
<div class="modal-content">
<h2>Редактировать заявку</h2>
<form id="editForm">
<input type="hidden" id="editId">
<label>Статус</label>
<div class="form-group">
<label for="editStatus">Статус заявки</label>
<select id="editStatus">
<option value="Новая">Новая</option>
<option value="В работе">В работе</option>
@@ -53,59 +90,206 @@
<option value="Завершена">Завершена</option>
<option value="Отменена">Отменена</option>
</select>
<label>Комментарий</label>
<textarea id="editComments" rows="3"></textarea>
<label>Переназначить на карточку клиента (по телефону)</label>
<input type="text" id="editPhone" placeholder="Телефон (любой формат)">
<button type="submit">Сохранить</button>
<button type="button" id="closeModal">Отмена</button>
</div>
<div class="form-group">
<label for="editComments">Комментарий</label>
<textarea id="editComments" rows="3" placeholder="Добавьте комментарий к заявке..."></textarea>
</div>
<div class="form-group">
<label for="editPhone">Переназначить на клиента (по телефону)</label>
<input type="text" id="editPhone" placeholder="Введите номер телефона">
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" id="saveBtn" style="flex:1;">Сохранить</button>
<button type="button" id="closeModal" class="btn-secondary">Отмена</button>
</div>
</form>
</div>
</div>
</main>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
// Проверка авторизации
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function getStatusClass(status) {
const map = {
'Новая': 'status-new',
'В работе': 'status-in-progress',
'Подтверждена': 'status-confirmed',
'Заселение': 'status-checkin',
'Завершена': 'status-completed',
'Отменена': 'status-cancelled'
};
return map[status] || 'status-new';
}
function showToast(message, type = 'info') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `<span>${escapeHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('toast-exit');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
async function getCsrfToken() {
const stored = window.localStorage.getItem('csrfToken');
if (stored) return stored;
try {
const res = await fetch('/api/csrf-token');
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
return data.csrfToken;
} catch (e) {
return null;
}
}
fetch('/api/me').then(r => r.json()).then(data => {
if (!data.isAdmin) window.location.href = '/login.html';
});
const tableBody = document.querySelector('#bookingsTable tbody');
const tableBody = document.getElementById('bookingsBody');
const bookingCards = document.getElementById('bookingCards');
const modal = document.getElementById('editModal');
const emptyState = document.getElementById('emptyState');
const tableContainer = document.getElementById('tableContainer');
let currentBookings = [];
function updateStats(bookings) {
const total = bookings.length;
const stats = { 'Новая': 0, 'В работе': 0, 'Подтверждена': 0 };
bookings.forEach(b => {
if (stats[b.status] !== undefined) stats[b.status]++;
});
animateValue('statTotal', total);
animateValue('statNew', stats['Новая']);
animateValue('statInProgress', stats['В работе']);
animateValue('statConfirmed', stats['Подтверждена']);
}
function animateValue(id, value) {
const el = document.getElementById(id);
const start = parseInt(el.textContent) || 0;
const duration = 500;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
el.textContent = Math.round(start + (value - start) * progress);
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
async function loadBookings() {
const status = document.getElementById('statusFilter').value;
const search = document.getElementById('searchInput').value;
let url = '/api/bookings?';
if (status) url += `status=${encodeURIComponent(status)}&`;
if (search) url += `search=${encodeURIComponent(search)}&`;
tableBody.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></td></tr>';
bookingCards.innerHTML = '<div style="text-align:center;padding:2rem;"><div class="loading-spinner"></div></div>';
try {
const res = await fetch(url);
if (!res.ok) throw new Error('Ошибка загрузки');
currentBookings = await res.json();
updateStats(currentBookings);
renderTable();
renderCards();
} catch (err) {
showToast('Ошибка загрузки заявок', 'error');
tableBody.innerHTML = '';
bookingCards.innerHTML = '';
}
}
function renderTable() {
tableBody.innerHTML = '';
currentBookings.forEach(b => {
if (currentBookings.length === 0) {
tableContainer.style.display = 'none';
bookingCards.innerHTML = '';
emptyState.style.display = 'block';
return;
}
tableContainer.style.display = 'block';
emptyState.style.display = 'none';
currentBookings.forEach((b, i) => {
const row = document.createElement('tr');
row.style.animationDelay = `${i * 0.03}s`;
row.innerHTML = `
<td>${b.external_id || '-'}</td>
<td>${b.name}</td>
<td>${b.phone_raw}</td>
<td>${b.checkin_date} ${b.checkout_date}</td>
<td>${b.status}</td>
<td><button class="editBtn" data-id="${b.id}">Изменить</button></td>
<td>${escapeHtml(b.external_id) || ''}</td>
<td><strong>${escapeHtml(b.name)}</strong></td>
<td>${escapeHtml(b.phone_raw)}</td>
<td>${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</td>
<td><span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span></td>
<td><button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Изменить</button></td>
`;
tableBody.appendChild(row);
});
}
document.getElementById('searchBtn').addEventListener('click', loadBookings);
function renderCards() {
bookingCards.innerHTML = '';
if (currentBookings.length === 0) return;
emptyState.style.display = 'none';
currentBookings.forEach((b, i) => {
const card = document.createElement('div');
card.className = 'booking-card';
card.style.animationDelay = `${i * 0.03}s`;
card.innerHTML = `
<div class="booking-card-header">
<div>
<div class="booking-card-name">${escapeHtml(b.name)}</div>
<div class="booking-card-id">ID: ${escapeHtml(b.external_id) || '—'}</div>
</div>
<span class="status-badge ${getStatusClass(b.status)}">${escapeHtml(b.status)}</span>
</div>
<div class="booking-card-body">
<div class="booking-card-field">
<span class="booking-card-label">Телефон</span>
<span class="booking-card-value">${escapeHtml(b.phone_raw)}</span>
</div>
<div class="booking-card-field">
<span class="booking-card-label">Даты</span>
<span class="booking-card-value">${escapeHtml(b.checkin_date)} ${escapeHtml(b.checkout_date)}</span>
</div>
</div>
<div class="booking-card-footer">
<button class="editBtn btn-secondary btn-sm" data-id="${b.id}">Редактировать</button>
</div>
`;
bookingCards.appendChild(card);
});
}
document.getElementById('statusFilter').addEventListener('change', loadBookings);
// Редактирование
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(loadBookings, 400);
});
document.getElementById('searchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') { clearTimeout(searchTimeout); loadBookings(); }
});
document.addEventListener('click', (e) => {
if (e.target.classList.contains('editBtn')) {
const id = e.target.dataset.id;
@@ -115,40 +299,53 @@
document.getElementById('editStatus').value = booking.status;
document.getElementById('editComments').value = booking.comments || '';
document.getElementById('editPhone').value = '';
modal.style.display = 'flex';
modal.classList.add('active');
}
});
document.getElementById('closeModal').addEventListener('click', () => {
modal.style.display = 'none';
});
document.getElementById('closeModal').addEventListener('click', () => modal.classList.remove('active'));
modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('active'); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.classList.contains('active')) modal.classList.remove('active'); });
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const saveBtn = document.getElementById('saveBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="loading-spinner"></span>';
const id = document.getElementById('editId').value;
const status = document.getElementById('editStatus').value;
const comments = document.getElementById('editComments').value;
const phone = document.getElementById('editPhone').value.trim();
const body = {};
const body = { comments };
if (status) body.status = status;
body.comments = comments;
if (phone) body.phone = phone;
const csrfToken = await getCsrfToken();
try {
const res = await fetch(`/api/bookings/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify(body)
});
if (res.ok) {
modal.style.display = 'none';
modal.classList.remove('active');
showToast('Заявка обновлена', 'success');
loadBookings();
} else {
alert('Ошибка сохранения');
const err = await res.json();
showToast(err.error || 'Ошибка сохранения', 'error');
}
} catch (err) {
showToast('Ошибка соединения', 'error');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = 'Сохранить';
}
});
// Первоначальная загрузка
loadBookings();
</script>
</body>

View File

@@ -4,41 +4,197 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="style.css">
<title>Вход</title>
<title>Вход в систему</title>
<style>
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.login-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.login-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: clamp(1.5rem, 5vw, 3rem);
width: 100%;
max-width: 420px;
box-shadow: var(--shadow-lg);
animation: fadeInUp 0.5s ease-out;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.login-icon {
width: 56px;
height: 56px;
background: var(--primary-light);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
}
.login-icon svg {
width: 28px;
height: 28px;
color: var(--primary);
}
.login-title {
font-size: clamp(1.375rem, 4vw, 1.75rem);
font-weight: 700;
text-align: center;
margin-bottom: 0.375rem;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.login-subtitle {
color: var(--text-muted);
text-align: center;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.login-btn {
width: 100%;
padding: 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
margin-top: 0.5rem;
justify-content: center;
}
.error-message {
background: var(--danger-light);
border: 1px solid #fca5a5;
color: var(--danger);
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
margin-top: 1rem;
display: none;
animation: shake 0.4s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem 0.875rem;
font-size: 0.9375rem;
}
</style>
</head>
<body>
<div class="login-page">
<header></header>
<main>
<h1>Вход в систему управления</h1>
<main class="login-main">
<div class="login-card">
<div class="login-icon">
<svg fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545L12.75 2.25 5.25 3.545V21"/></svg>
</div>
<h1 class="login-title">Отель 777</h1>
<p class="login-subtitle">Панель управления</p>
<form id="loginForm">
<label>Логин</label>
<input type="text" name="login" required>
<label>Пароль</label>
<input type="password" name="password" required>
<button type="submit">Войти</button>
<p id="error" style="color: red; display: none;"></p>
<div class="form-group">
<label for="login">Логин</label>
<input type="text" id="login" name="login" required autocomplete="username" placeholder="Введите логин">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="Введите пароль">
</div>
<button type="submit" class="login-btn" id="loginBtn">
<span id="loginBtnText">Войти</span>
<span id="loginBtnSpinner" class="loading-spinner" style="display:none;"></span>
</button>
<p id="error" class="error-message"></p>
</form>
</div>
</main>
</div>
<script src="nav.js"></script>
<script src="seo.js"></script>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const loginBtn = document.getElementById('loginBtn');
const loginBtnText = document.getElementById('loginBtnText');
const loginBtnSpinner = document.getElementById('loginBtnSpinner');
const errorEl = document.getElementById('error');
loginBtn.disabled = true;
loginBtnText.style.display = 'none';
loginBtnSpinner.style.display = 'inline-block';
errorEl.style.display = 'none';
try {
const csrfRes = await fetch('/api/csrf-token');
const csrfData = await csrfRes.json();
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfData.csrfToken
},
body: JSON.stringify({
login: form.login.value,
password: form.password.value
login: document.getElementById('login').value,
password: document.getElementById('password').value
})
});
if (res.ok) {
window.location.href = '/admin.html';
const data = await res.json();
window.localStorage.setItem('csrfToken', data.csrfToken);
window.location.href = '/index.html';
} else {
const err = document.getElementById('error');
err.textContent = 'Неверный логин или пароль';
err.style.display = 'block';
const err = await res.json();
errorEl.textContent = err.error || 'Неверный логин или пароль';
errorEl.style.display = 'block';
}
} catch (err) {
errorEl.textContent = 'Ошибка соединения с сервером';
errorEl.style.display = 'block';
} finally {
loginBtn.disabled = false;
loginBtnText.style.display = 'inline';
loginBtnSpinner.style.display = 'none';
}
});
</script>

View File

@@ -1,16 +1,35 @@
document.addEventListener('DOMContentLoaded', () => {
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
const navHTML = `
<nav>
<a href="/">Главная</a>
<a href="/clients.html">Клиенты</a>
<a href="/admin.html">Панель управления</a>
<a href="/index.html" data-page="index.html">Заявки</a>
<a href="/clients.html" data-page="clients.html">Клиенты</a>
<a href="/admin.html" data-page="admin.html">Панель управления</a>
<a id="logoutLink" href="#" style="display:none;">Выход</a>
</nav>
`;
const header = document.querySelector('header');
if (header) header.innerHTML += navHTML;
// Показать/скрыть выход, проверив сессию
const pageMap = {
'': 'index.html',
'index.html': 'index.html',
'clients.html': 'clients.html',
'client.html': 'clients.html',
'admin.html': 'admin.html',
'login.html': 'login.html'
};
const activePage = pageMap[currentPage] || '';
document.querySelectorAll('nav a[data-page]').forEach(link => {
if (link.dataset.page === activePage) {
link.classList.add('active');
}
});
fetch('/api/me')
.then(r => r.json())
.then(data => {
@@ -23,7 +42,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.target.id === 'logoutLink') {
e.preventDefault();
fetch('/api/logout', { method: 'POST' })
.then(() => window.location.href = '/login.html');
.then(() => {
window.localStorage.removeItem('csrfToken');
window.location.href = '/login.html';
});
}
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ const bcrypt = require('bcrypt');
const cron = require('node-cron');
const { db, normalizePhone, logAction } = require('./db');
const { sessionMiddleware, ensureAdmin, requireAdmin } = require('./auth');
const { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin } = require('./auth');
const { syncBookings } = require('./sync');
const { notifyBookingUpdate, verifyEmailConnection } = require('./mailer');
@@ -15,10 +15,16 @@ const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(sessionMiddleware);
app.use(injectCsrfToken);
// Статика из public
app.use(express.static(path.join(__dirname, 'public')));
// CSRF token endpoint
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.session.csrfToken });
});
// === Инициализация ===
// Асинхронное создание/обновление администратора из .env
(async () => {
@@ -33,20 +39,29 @@ verifyEmailConnection().catch(err => {
// === API ===
// Вход администратора (с хэшированием и сохранением adminId)
app.post('/api/login', async (req, res) => {
app.post('/api/login', csrfProtection, async (req, res) => {
try {
const { login, password } = req.body;
if (!login || !password) {
return res.status(400).json({ error: 'Логин и пароль обязательны' });
}
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 });
req.session.csrfToken = req.session.csrfToken;
res.json({ success: true, csrfToken: req.session.csrfToken });
} else {
res.status(401).json({ error: 'Неверный логин или пароль' });
}
} catch (err) {
console.error('Ошибка входа:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Выход
app.post('/api/logout', (req, res) => {
app.post('/api/logout', csrfProtection, (req, res) => {
req.session.destroy();
res.json({ success: true });
});
@@ -60,16 +75,25 @@ app.get('/api/me', (req, res) => {
// Получить список всех администраторов (только id и login)
app.get('/api/admins', requireAdmin, (req, res) => {
try {
const admins = db.prepare('SELECT id, login FROM admins ORDER BY id').all();
res.json(admins);
} catch (err) {
console.error('Ошибка получения администраторов:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Добавить нового администратора
app.post('/api/admins', requireAdmin, async (req, res) => {
app.post('/api/admins', requireAdmin, csrfProtection, async (req, res) => {
try {
const { login, password } = req.body;
if (!login || !password) {
return res.status(400).json({ error: 'Логин и пароль обязательны' });
}
if (login.length < 3 || password.length < 6) {
return res.status(400).json({ error: 'Логин минимум 3 символа, пароль минимум 6 символов' });
}
const existing = db.prepare('SELECT id FROM admins WHERE login = ?').get(login);
if (existing) {
return res.status(409).json({ error: 'Администратор с таким логином уже существует' });
@@ -79,15 +103,23 @@ app.post('/api/admins', requireAdmin, async (req, res) => {
const stmt = db.prepare('INSERT INTO admins (login, password) VALUES (?, ?)');
const info = stmt.run(login, hashed);
res.status(201).json({ id: info.lastInsertRowid, login });
} catch (err) {
console.error('Ошибка создания администратора:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Изменить пароль администратора
app.put('/api/admins/:id', requireAdmin, async (req, res) => {
app.put('/api/admins/:id', requireAdmin, csrfProtection, async (req, res) => {
try {
const { id } = req.params;
const { password } = req.body;
if (!password) {
return res.status(400).json({ error: 'Новый пароль обязателен' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Пароль минимум 6 символов' });
}
const admin = db.prepare('SELECT id, login FROM admins WHERE id = ?').get(id);
if (!admin) {
return res.status(404).json({ error: 'Администратор не найден' });
@@ -96,17 +128,20 @@ app.put('/api/admins/:id', requireAdmin, async (req, res) => {
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 });
} catch (err) {
console.error('Ошибка обновления пароля:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Удалить администратора
app.delete('/api/admins/:id', requireAdmin, (req, res) => {
app.delete('/api/admins/:id', requireAdmin, csrfProtection, (req, res) => {
try {
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: 'Нельзя удалить свою учётную запись' });
}
@@ -116,12 +151,17 @@ app.delete('/api/admins/:id', requireAdmin, (req, res) => {
return res.status(404).json({ error: 'Администратор не найден' });
}
res.json({ success: true });
} catch (err) {
console.error('Ошибка удаления администратора:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// --- Заявки (bookings) ---
// Список заявок с фильтрами
app.get('/api/bookings', requireAdmin, (req, res) => {
try {
const { status, search, client_id } = req.query;
let query = 'SELECT * FROM bookings WHERE 1=1';
const params = [];
@@ -141,17 +181,27 @@ app.get('/api/bookings', requireAdmin, (req, res) => {
query += ' ORDER BY created_at DESC';
const bookings = db.prepare(query).all(...params);
res.json(bookings);
} catch (err) {
console.error('Ошибка получения заявок:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Детали заявки
app.get('/api/bookings/:id', requireAdmin, (req, res) => {
try {
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id);
if (!booking) return res.status(404).json({ error: 'Заявка не найдена' });
res.json(booking);
} catch (err) {
console.error('Ошибка получения заявки:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Обновление заявки (статус, комментарий, перепривязка клиента)
app.put('/api/bookings/:id', requireAdmin, (req, res) => {
app.put('/api/bookings/:id', requireAdmin, csrfProtection, (req, res) => {
try {
const { id } = req.params;
const { status, comments, phone } = req.body;
@@ -160,7 +210,6 @@ app.put('/api/bookings/:id', requireAdmin, (req, res) => {
const changes = {};
// Обновление статуса
if (status && status !== booking.status) {
const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена'];
if (!allowed.includes(status)) {
@@ -170,13 +219,11 @@ app.put('/api/bookings/:id', requireAdmin, (req, res) => {
changes.status = { from: booking.status, to: status };
}
// Обновление комментария
if (comments !== undefined && comments !== booking.comments) {
db.prepare('UPDATE bookings SET comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(comments, id);
changes.comments = { from: booking.comments, to: comments };
}
// Перепривязка к другой карточке по номеру телефона
if (phone) {
const normPhone = normalizePhone(phone);
const user = db.prepare('SELECT id FROM users WHERE phone = ?').get(normPhone);
@@ -189,7 +236,6 @@ app.put('/api/bookings/:id', requireAdmin, (req, res) => {
}
}
// Если были изменения логируем и шлём уведомление
if (Object.keys(changes).length > 0) {
logAction(`Изменение заявки #${id}`, changes);
const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
@@ -198,12 +244,17 @@ app.put('/api/bookings/:id', requireAdmin, (req, res) => {
const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
res.json(updatedBooking);
} catch (err) {
console.error('Ошибка обновления заявки:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// --- Карточки клиентов ---
// Список клиентов
app.get('/api/clients', requireAdmin, (req, res) => {
try {
const { search } = req.query;
let query = 'SELECT * FROM users WHERE 1=1';
const params = [];
@@ -214,10 +265,15 @@ app.get('/api/clients', requireAdmin, (req, res) => {
query += ' ORDER BY name ASC';
const clients = db.prepare(query).all(...params);
res.json(clients);
} catch (err) {
console.error('Ошибка получения клиентов:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Профиль клиента с его заявками
app.get('/api/clients/:id', requireAdmin, (req, res) => {
try {
const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!client) return res.status(404).json({ error: 'Клиент не найден' });
@@ -228,14 +284,23 @@ app.get('/api/clients/:id', requireAdmin, (req, res) => {
`).all(req.params.id);
res.json({ client, bookings });
} catch (err) {
console.error('Ошибка получения профиля клиента:', err);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// --- Синхронизация ---
// Ручной запуск синхронизации
app.post('/api/admin/sync', requireAdmin, async (req, res) => {
app.post('/api/admin/sync', requireAdmin, csrfProtection, async (req, res) => {
try {
await syncBookings();
res.json({ success: true, message: 'Синхронизация запущена' });
} catch (err) {
console.error('Ошибка синхронизации:', err);
res.status(500).json({ error: 'Ошибка синхронизации' });
}
});
// Планировщик синхронизации каждые 5 минут