diff --git a/auth.js b/auth.js index 42b4702..39c5438 100644 --- a/auth.js +++ b/auth.js @@ -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 }; \ No newline at end of file +module.exports = { sessionMiddleware, csrfProtection, injectCsrfToken, ensureAdmin, requireAdmin }; \ No newline at end of file diff --git a/public/admin.html b/public/admin.html index 970d653..f9860f2 100644 --- a/public/admin.html +++ b/public/admin.html @@ -4,128 +4,249 @@ + Панель управления
+ +
+

Панель управления

-

Сервис:

- -

+ +
+

Синхронизация заявок

+

Получение актуальных данных из внешней системы бронирования

+ +

+
-
+

Администраторы

+ +
+ + + + + + + + + +
IDЛогинДействия
+
-

Управление администраторами

- - - - - -
IDЛогинДействия
+

Добавить администратора

+
+
+
+ + +
+ +
+ + +
+ + +
+
-

Добавить нового администратора

-
- - - - - -
- - -
+ - \ No newline at end of file + diff --git a/public/client.html b/public/client.html index 54e303f..8e864e9 100644 --- a/public/client.html +++ b/public/client.html @@ -4,62 +4,270 @@ + Карточка клиента +
+ +
+
+ + + Назад + +

Профиль клиента

-
-

Заявки (сортировка: сначала новые, потом по статусу А-Я)

- - - - - -
ID внеш.ИмяДатыСтатусКомментарий
-

← Назад к списку

+ +
+ +

История заявок

+ +
+ + + + + + + + + + + +
IDИмяДатыСтатусКомментарий
+
+ +
+ +
+ - \ No newline at end of file + diff --git a/public/clients.html b/public/clients.html index 614622f..b5640ad 100644 --- a/public/clients.html +++ b/public/clients.html @@ -4,50 +4,193 @@ + Клиенты +
+ +
+

Клиенты

- - - - - - - -
IDТелефонИмяДействия
+ +
+
+ +
+
+ +
+ + + + + + + + + + +
IDТелефонИмяДействия
+
+ +
+ +
+ - \ No newline at end of file + diff --git a/public/index.html b/public/index.html index ef855fc..82fbff8 100644 --- a/public/index.html +++ b/public/index.html @@ -4,108 +4,292 @@ + Заявки на заселение
+ +
+

Заявки на заселение

-
- - - + +
+
+
0
+
Всего
+
+
+
0
+
Новые
+
+
+
0
+
В работе
+
+
+
0
+
Подтверждены
+
+
+ +
+
+ +
+ +
+ +
+ + + + + + + + + + + + +
IDИмя гостяТелефонДаты проживанияСтатусДействия
+
+ +
+ + - - - - - - - - - - - - -
ID внеш.ИмяТелефонДатыСтатусДействия
- -
+ - \ No newline at end of file + diff --git a/public/login.html b/public/login.html index 6279039..8c5e5f5 100644 --- a/public/login.html +++ b/public/login.html @@ -4,43 +4,199 @@ - Вход + Вход в систему + -
-
-

Вход в систему управления

-
- - - - - - -
-
+
+
+ +
+ +
+
+ - - \ No newline at end of file + diff --git a/public/nav.js b/public/nav.js index c1767f4..d952788 100644 --- a/public/nav.js +++ b/public/nav.js @@ -1,16 +1,35 @@ document.addEventListener('DOMContentLoaded', () => { + const currentPage = window.location.pathname.split('/').pop() || 'index.html'; + const navHTML = ` `; + 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'; + }); } }); -}); \ No newline at end of file +}); diff --git a/public/style.css b/public/style.css index eea9a41..babfa56 100644 --- a/public/style.css +++ b/public/style.css @@ -1,26 +1,1044 @@ -* { box-sizing: border-box; margin: 0; padding: 0; } -body { font-family: 'Segoe UI', sans-serif; background: #f4f6f9; color: #333; min-height: 100vh; } -header { background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,0.1); padding: 1rem 2rem; } -nav { display: flex; gap: 1rem; flex-wrap: wrap; } -nav a { text-decoration: none; color: #2c3e50; font-weight: 500; padding: 0.5rem 0; } -nav a:hover { color: #3498db; } -main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; } -h1, h2 { color: #2c3e50; margin-bottom: 1rem; } -table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } -th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #eee; } -th { background: #fafbfc; font-weight: 600; } -tr:hover { background: #f1f4f8; } -button, .btn { padding: 0.5rem 1rem; background: #3498db; color: #fff; border: none; border-radius: 4px; cursor: pointer; } -button:hover { background: #2980b9; } -form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 0 auto; } -input, select, textarea { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; } -label { font-weight: 500; } -.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.4); display: flex; justify-content: center; align-items: center; } -.modal-content { background: #fff; padding: 2rem; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; overflow-y: auto; } -@media (max-width: 600px) { - header { padding: 0.5rem 1rem; } - nav { flex-direction: column; } +:root { + --primary: #6366f1; + --primary-hover: #4f46e5; + --primary-light: rgba(99, 102, 241, 0.08); + --secondary: #0ea5e9; + --success: #10b981; + --success-light: rgba(16, 185, 129, 0.1); + --warning: #f59e0b; + --warning-light: rgba(245, 158, 11, 0.1); + --danger: #ef4444; + --danger-light: rgba(239, 68, 68, 0.08); + --info: #3b82f6; + --info-light: rgba(59, 130, 246, 0.1); + + --bg-primary: #f8fafc; + --bg-secondary: #ffffff; + --bg-card: #ffffff; + --bg-subtle: #f1f5f9; + --bg-elevated: #ffffff; + + --text-primary: #0f172a; + --text-secondary: #475569; + --text-muted: #94a3b8; + --text-inverse: #ffffff; + + --border: #e2e8f0; + --border-light: #f1f5f9; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04); + --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.15); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + scroll-behavior: smooth; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +header { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border); + padding: 0.875rem 2rem; + position: sticky; + top: 0; + z-index: 100; + animation: slideDown 0.5s ease-out; +} + +@keyframes slideDown { + from { transform: translateY(-100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +nav { + display: flex; + gap: 0.5rem; + align-items: center; +} + +nav a { + text-decoration: none; + color: var(--text-secondary); + font-weight: 500; + padding: 0.5rem 1rem; + border-radius: var(--radius-sm); + transition: all var(--transition-base); + font-size: 0.9375rem; + white-space: nowrap; +} + +nav a:hover { + color: var(--text-primary); + background: var(--bg-subtle); +} + +nav a.active { + color: var(--primary); + background: var(--primary-light); + font-weight: 600; +} + +main { + max-width: 100%; + margin: 0 auto; + padding: 1.5rem 2rem; + animation: fadeIn 0.6s ease-out; + width: 100%; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +h1 { + color: var(--text-primary); + font-size: clamp(1.5rem, 3vw, 2.25rem); + font-weight: 700; + margin-bottom: 1.5rem; + letter-spacing: -0.025em; +} + +h2 { + color: var(--text-primary); + font-size: clamp(1.125rem, 2vw, 1.5rem); + font-weight: 600; + margin: 2rem 0 1rem; + letter-spacing: -0.015em; +} + +h3 { + color: var(--text-secondary); + font-size: clamp(1rem, 1.5vw, 1.125rem); + font-weight: 600; + margin: 1.5rem 0 1rem; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + transition: all var(--transition-base); +} + +.card:hover { + box-shadow: var(--shadow-md); +} + +.table-container { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +table { + width: 100%; + border-collapse: collapse; +} + +th { + background: var(--bg-subtle); + color: var(--text-secondary); + font-weight: 600; + padding: 0.875rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); + text-transform: uppercase; + font-size: 0.6875rem; + letter-spacing: 0.05em; + white-space: nowrap; +} + +td { + padding: 1rem; + border-bottom: 1px solid var(--border-light); + color: var(--text-secondary); + vertical-align: middle; +} + +tr { + transition: background var(--transition-fast); +} + +tr:hover { + background: var(--bg-subtle); +} + +tr:last-child td { + border-bottom: none; +} + +tr { + animation: rowSlideIn 0.3s ease-out backwards; +} + +tr:nth-child(1) { animation-delay: 0.03s; } +tr:nth-child(2) { animation-delay: 0.06s; } +tr:nth-child(3) { animation-delay: 0.09s; } +tr:nth-child(4) { animation-delay: 0.12s; } +tr:nth-child(5) { animation-delay: 0.15s; } +tr:nth-child(6) { animation-delay: 0.18s; } +tr:nth-child(7) { animation-delay: 0.21s; } +tr:nth-child(8) { animation-delay: 0.24s; } +tr:nth-child(9) { animation-delay: 0.27s; } +tr:nth-child(10) { animation-delay: 0.3s; } + +@keyframes rowSlideIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +button, .btn { + padding: 0.625rem 1.25rem; + background: var(--primary); + color: var(--text-inverse); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: 500; + font-size: 0.875rem; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +button:hover, .btn:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +button:active, .btn:active { + transform: translateY(0); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); +} + +.btn-secondary:hover { + background: var(--bg-subtle); + border-color: var(--text-muted); + box-shadow: var(--shadow-md); +} + +.btn-success { + background: var(--success); +} + +.btn-success:hover { + background: #059669; +} + +.btn-danger { + background: var(--danger); +} + +.btn-danger:hover { + background: #dc2626; +} + +.btn-sm { + padding: 0.375rem 0.75rem; + font-size: 0.8125rem; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; +} + +.status-new { + background: var(--info-light); + color: var(--info); +} +.status-new::before { background: var(--info); } + +.status-in-progress { + background: var(--warning-light); + color: #b45309; +} +.status-in-progress::before { background: var(--warning); } + +.status-confirmed { + background: var(--success-light); + color: #047857; +} +.status-confirmed::before { background: var(--success); } + +.status-checkin { + background: rgba(139, 92, 246, 0.1); + color: #7c3aed; +} +.status-checkin::before { background: #8b5cf6; } + +.status-completed { + background: var(--bg-subtle); + color: var(--text-muted); +} +.status-completed::before { background: var(--text-muted); } + +.status-cancelled { + background: var(--danger-light); + color: var(--danger); +} +.status-cancelled::before { background: var(--danger); } + +form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +input, select, textarea { + padding: 0.625rem 0.875rem; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9375rem; + transition: all var(--transition-base); + font-family: inherit; + width: 100%; +} + +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--primary-light); +} + +input::placeholder, textarea::placeholder { + color: var(--text-muted); +} + +label { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(4px); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all var(--transition-base); + padding: 1rem; +} + +.modal.active { + opacity: 1; + visibility: visible; +} + +.modal-content { + background: var(--bg-elevated); + padding: 2rem; + border-radius: var(--radius-xl); + width: 100%; + max-width: 560px; + max-height: 85vh; + overflow-y: auto; + border: 1px solid var(--border); + box-shadow: var(--shadow-xl); + transform: scale(0.95) translateY(10px); + transition: all var(--transition-slow); +} + +.modal.active .modal-content { + transform: scale(1) translateY(0); +} + +.toast-container { + position: fixed; + top: 5rem; + right: 1.5rem; + z-index: 2000; + display: flex; + flex-direction: column; + gap: 0.5rem; + pointer-events: none; +} + +.toast { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.875rem 1.25rem; + box-shadow: var(--shadow-lg); + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 280px; + max-width: 400px; + animation: toastSlideIn 0.3s ease-out; + pointer-events: auto; + font-size: 0.875rem; +} + +.toast.toast-exit { + animation: toastSlideOut 0.25s ease-in forwards; +} + +@keyframes toastSlideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes toastSlideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +.toast-success { border-left: 3px solid var(--success); } +.toast-error { border-left: 3px solid var(--danger); } +.toast-warning { border-left: 3px solid var(--warning); } +.toast-info { border-left: 3px solid var(--info); } + +.loading-spinner { + display: inline-block; + width: 18px; + height: 18px; + border: 2px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.filter-bar { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-card); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); +} + +.filter-bar input, .filter-bar select { + flex: 1; + min-width: 180px; +} + +.search-input-wrapper { + position: relative; + flex: 2; + min-width: 240px; +} + +.search-input-wrapper::before { + content: ''; + position: absolute; + left: 0.875rem; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; +} + +.search-input-wrapper input { + padding-left: 2.5rem; +} + +.action-buttons { + display: flex; + gap: 0.375rem; + flex-wrap: nowrap; +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 3rem; + margin-bottom: 1rem; + opacity: 0.4; +} + +.empty-state h3 { + color: var(--text-secondary); + margin: 0 0 0.5rem; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; + text-align: center; + transition: all var(--transition-base); + box-shadow: var(--shadow-sm); +} + +.stat-card:hover { + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.stat-value { + font-size: clamp(1.5rem, 3vw, 2rem); + font-weight: 700; + color: var(--primary); + line-height: 1.2; +} + +.stat-label { + color: var(--text-muted); + font-size: 0.8125rem; + margin-top: 0.25rem; + font-weight: 500; +} + +.form-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + max-width: 480px; +} + +hr { + margin: 2rem 0; + border: none; + border-top: 1px solid var(--border); +} + +/* ======================================== + BOOKING CARDS (mobile/tablet view) + ======================================== */ +.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-name { + font-weight: 600; + font-size: 1rem; + color: var(--text-primary); +} + +.booking-card-id { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; +} + +.booking-card-body { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.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-footer { + display: flex; + justify-content: flex-end; + padding-top: 0.75rem; + border-top: 1px solid var(--border-light); +} + +@keyframes cardSlideIn { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ======================================== + RESPONSIVE — TABLET (≤1024px) + ======================================== */ +@media (max-width: 1024px) { + header { + padding: 0.75rem 1.5rem; + } + + main { + padding: 1.25rem 1.5rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .filter-bar { + padding: 0.875rem; + } +} + +/* ======================================== + RESPONSIVE — PHONE (≤768px) + ======================================== */ +@media (max-width: 768px) { + html { + font-size: 15px; + } + + header { + padding: 0.625rem 1rem; + } + + nav { + gap: 0.25rem; + } + + nav a { + padding: 0.4rem 0.625rem; + font-size: 0.8125rem; + } + + main { + padding: 1rem; + } + + h1 { + margin-bottom: 1rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-value { + font-size: 1.5rem; + } + + .filter-bar { + flex-direction: column; + gap: 0.5rem; + padding: 0.875rem; + } + + .filter-bar input, + .filter-bar select { + min-width: 100%; + width: 100%; + } + + .search-input-wrapper { + min-width: 100%; + } + + /* Hide tables, show cards */ + .table-container { + display: none !important; + } + + .booking-cards { + display: block; + } + + .toast-container { + top: 4.5rem; + right: 0.75rem; + left: 0.75rem; + } + + .toast { + min-width: auto; + max-width: none; + width: 100%; + } + + .modal-content { + padding: 1.5rem; + max-width: 100%; + margin: 0.5rem; + } + + .action-buttons { + flex-direction: row; + } + + .action-buttons button { + flex: 1; + } + + .form-card { + max-width: 100%; + } + + form { + max-width: 100%; + } +} + +/* ======================================== + RESPONSIVE — SMALL PHONE (≤480px) + ======================================== */ +@media (max-width: 480px) { + html { + font-size: 14px; + } + + header { + padding: 0.5rem 0.75rem; + } + + nav { + gap: 0.125rem; + } + + nav a { + padding: 0.375rem 0.5rem; + font-size: 0.75rem; + } + + main { + padding: 0.75rem; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + } + + .stat-card { + padding: 0.875rem 0.5rem; + } + + .stat-label { + font-size: 0.6875rem; + } + + .booking-card { + padding: 1rem; + } + + .booking-card-body { + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .card { + padding: 1rem; + } + + .modal-content { + padding: 1.25rem; + } +} + +/* ======================================== + RESPONSIVE — LARGE SCREENS (≥1440px) + ======================================== */ +@media (min-width: 1440px) { + html { + font-size: 17px; + } + + main { + max-width: 1440px; + padding: 2rem 3rem; + } + + header { + padding: 1rem 3rem; + } + + .stats-grid { + grid-template-columns: repeat(4, 1fr); + gap: 1.25rem; + } + + .stat-card { + padding: 1.5rem; + } + + .filter-bar { + padding: 1.25rem; + } +} + +/* ======================================== + RESPONSIVE — XL / TV (≥1920px) + ======================================== */ +@media (min-width: 1920px) { + html { + font-size: 18px; + } + + main { + max-width: 1680px; + padding: 2.5rem 4rem; + } + + header { + padding: 1.25rem 4rem; + } + + nav a { + font-size: 1rem; + padding: 0.625rem 1.25rem; + } + + .stats-grid { + grid-template-columns: repeat(4, 1fr); + gap: 1.5rem; + } + + .stat-card { + padding: 2rem; + } + + .stat-value { + font-size: 2.5rem; + } + + .stat-label { + font-size: 0.9375rem; + } + + .table-container { + border-radius: var(--radius-xl); + } + + th { + padding: 1rem 1.25rem; + font-size: 0.75rem; + } + + td { + padding: 1.125rem 1.25rem; + } + + .filter-bar { + padding: 1.5rem; + gap: 1rem; + border-radius: var(--radius-xl); + } + + .filter-bar input, + .filter-bar select { + padding: 0.875rem 1rem; + font-size: 1rem; + } + + button, .btn { + padding: 0.875rem 1.75rem; + font-size: 1rem; + } + + .card { + padding: 2rem; + border-radius: var(--radius-xl); + } + + .form-card { + max-width: 560px; + padding: 2rem; + } + + .modal-content { + max-width: 640px; + padding: 2.5rem; + } + + .booking-card { + padding: 1.5rem; + margin-bottom: 1rem; + } +} + +/* ======================================== + RESPONSIVE — 4K / HUGE TV (≥2560px) + ======================================== */ +@media (min-width: 2560px) { + html { + font-size: 20px; + } + + main { + max-width: 2000px; + padding: 3rem 5rem; + } + + header { + padding: 1.5rem 5rem; + } + + .stats-grid { + gap: 2rem; + } + + .stat-card { + padding: 2.5rem; + } + + .stat-value { + font-size: 3rem; + } + + .stat-label { + font-size: 1.125rem; + } + + nav a { + font-size: 1.125rem; + padding: 0.75rem 1.5rem; + } +} + +/* ======================================== + PRINT STYLES + ======================================== */ +@media print { + header, .filter-bar, .action-buttons, .toast-container, button, .btn { + display: none !important; + } + + body { + background: white; + color: black; + } + + .card, .table-container { + box-shadow: none; + border: 1px solid #ccc; + } + + main { + padding: 0; + max-width: 100%; + } } -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 6f10b33..f67fe66 100644 --- a/server.js +++ b/server.js @@ -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) => { - const { login, password } = req.body; - 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: 'Неверный логин или пароль' }); +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; + 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,182 +75,232 @@ app.get('/api/me', (req, res) => { // Получить список всех администраторов (только 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); + 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) => { - const { login, password } = req.body; - if (!login || !password) { - return res.status(400).json({ error: 'Логин и пароль обязательны' }); +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: 'Администратор с таким логином уже существует' }); + } + 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 }); + } catch (err) { + console.error('Ошибка создания администратора:', err); + res.status(500).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: 'Новый пароль обязателен' }); +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: 'Администратор не найден' }); + } + 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 }); + } catch (err) { + console.error('Ошибка обновления пароля:', err); + res.status(500).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: 'Нельзя удалить последнего администратора' }); +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: 'Нельзя удалить свою учётную запись' }); + } + 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 }); + } catch (err) { + console.error('Ошибка удаления администратора:', err); + res.status(500).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'; - const params = []; + try { + const { status, search, client_id } = req.query; + let query = 'SELECT * FROM bookings WHERE 1=1'; + const params = []; - if (status) { - query += ' AND status = ?'; - params.push(status); + if (status) { + query += ' AND status = ?'; + params.push(status); + } + if (client_id) { + query += ' AND user_id = ?'; + params.push(client_id); + } + if (search) { + query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)'; + params.push(`%${search}%`, `%${search}%`, `%${search}%`); + } + 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: 'Внутренняя ошибка сервера' }); } - if (client_id) { - query += ' AND user_id = ?'; - params.push(client_id); - } - if (search) { - query += ' AND (name LIKE ? OR phone_raw LIKE ? OR comments LIKE ?)'; - params.push(`%${search}%`, `%${search}%`, `%${search}%`); - } - query += ' ORDER BY created_at DESC'; - const bookings = db.prepare(query).all(...params); - res.json(bookings); }); // Детали заявки app.get('/api/bookings/:id', requireAdmin, (req, res) => { - const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id); - if (!booking) return res.status(404).json({ error: 'Заявка не найдена' }); - res.json(booking); + 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) => { - const { id } = req.params; - const { status, comments, phone } = req.body; +app.put('/api/bookings/:id', requireAdmin, csrfProtection, (req, res) => { + try { + const { id } = req.params; + 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: 'Заявка не найдена' }); + const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); + if (!booking) return res.status(404).json({ error: 'Заявка не найдена' }); - const changes = {}; + const changes = {}; - // Обновление статуса - if (status && status !== booking.status) { - const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена']; - if (!allowed.includes(status)) { - return res.status(400).json({ error: 'Недопустимый статус' }); + if (status && status !== booking.status) { + const allowed = ['Новая', 'В работе', 'Подтверждена', 'Заселение', 'Завершена', 'Отменена']; + if (!allowed.includes(status)) { + return res.status(400).json({ error: 'Недопустимый статус' }); + } + db.prepare('UPDATE bookings SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, id); + changes.status = { from: booking.status, to: status }; } - db.prepare('UPDATE bookings SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, id); - 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); - if (!user) { - return res.status(400).json({ error: 'Карточка клиента с таким телефоном не найдена' }); + 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 (user.id !== booking.user_id) { - db.prepare('UPDATE bookings SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id, id); - changes.user_id = { from: booking.user_id, to: user.id }; + + if (phone) { + const normPhone = normalizePhone(phone); + const user = db.prepare('SELECT id FROM users WHERE phone = ?').get(normPhone); + if (!user) { + return res.status(400).json({ error: 'Карточка клиента с таким телефоном не найдена' }); + } + if (user.id !== booking.user_id) { + db.prepare('UPDATE bookings SET user_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(user.id, id); + changes.user_id = { from: booking.user_id, to: user.id }; + } } - } - // Если были изменения – логируем и шлём уведомление - if (Object.keys(changes).length > 0) { - logAction(`Изменение заявки #${id}`, changes); - const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); - notifyBookingUpdate(updated, changes); - } + if (Object.keys(changes).length > 0) { + logAction(`Изменение заявки #${id}`, changes); + const updated = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); + notifyBookingUpdate(updated, changes); + } - const updatedBooking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id); - res.json(updatedBooking); + 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) => { - const { search } = req.query; - let query = 'SELECT * FROM users WHERE 1=1'; - const params = []; - if (search) { - query += ' AND (phone LIKE ? OR name LIKE ?)'; - params.push(`%${search}%`, `%${search}%`); + try { + const { search } = req.query; + let query = 'SELECT * FROM users WHERE 1=1'; + const params = []; + if (search) { + query += ' AND (phone LIKE ? OR name LIKE ?)'; + params.push(`%${search}%`, `%${search}%`); + } + 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: 'Внутренняя ошибка сервера' }); } - query += ' ORDER BY name ASC'; - const clients = db.prepare(query).all(...params); - 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: 'Клиент не найден' }); + try { + const client = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); + if (!client) return res.status(404).json({ error: 'Клиент не найден' }); - const bookings = db.prepare(` - SELECT * FROM bookings - WHERE user_id = ? - ORDER BY created_at DESC, status ASC - `).all(req.params.id); + const bookings = db.prepare(` + SELECT * FROM bookings + WHERE user_id = ? + ORDER BY created_at DESC, status ASC + `).all(req.params.id); - res.json({ client, bookings }); + res.json({ client, bookings }); + } catch (err) { + console.error('Ошибка получения профиля клиента:', err); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } }); // --- Синхронизация --- // Ручной запуск синхронизации -app.post('/api/admin/sync', requireAdmin, async (req, res) => { - await syncBookings(); - res.json({ success: true, message: 'Синхронизация запущена' }); +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 минут