прибрался

This commit is contained in:
2026-05-07 21:10:34 +05:00
parent 7d44320d50
commit f421398787
3 changed files with 713 additions and 8 deletions

View File

@@ -1,7 +1,9 @@
{
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"sharp": "^0.34.5",
"sqlite3": "^6.0.1"
},

536
public/admin.html Normal file
View File

@@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel — Hotel 777</title>
<link rel="stylesheet" href="css/bootstrap.min.css">
<link rel="stylesheet" href="css/fontawesome.min.css">
<link rel="stylesheet" href="css/inter-font.css">
<style>
:root { --primary: #2563eb; --gold: #c9a84c; --dark: #0f172a; --sidebar-width: 260px; }
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: #f1f5f9; margin: 0; }
#login-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: linear-gradient(135deg, #0f172a, #1e293b); }
.login-card { background: #fff; border-radius: 16px; padding: 48px 40px; width: 100%; max-width: 400px; box-shadow: 0 25px 60px rgba(0,0,0,0.3); }
.login-card h2 { font-family: 'Playfair Display', serif; text-align: center; margin-bottom: 8px; color: var(--dark); }
.login-card .subtitle { text-align: center; color: #64748b; margin-bottom: 32px; font-size: 0.9rem; }
.login-card .form-label { font-weight: 500; font-size: 0.85rem; color: #334155; }
.login-card .form-control { border-radius: 10px; padding: 12px 16px; border: 1px solid #e2e8f0; }
.login-card .form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.login-btn { width: 100%; padding: 14px; border: none; border-radius: 10px; background: var(--primary); color: #fff; font-weight: 600; font-size: 1rem; cursor: pointer; margin-top: 8px; }
.login-btn:hover { background: #1d4ed8; }
.login-error { color: #ef4444; font-size: 0.85rem; text-align: center; margin-top: 12px; display: none; }
.login-logo { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--gold); margin-bottom: 24px; }
.login-logo span { color: var(--primary); }
#admin-panel { display: none; }
.sidebar { position: fixed; left: 0; top: 0; bottom: 0; width: var(--sidebar-width); background: var(--dark); color: #fff; padding: 24px 0; z-index: 100; overflow-y: auto; }
.sidebar-brand { padding: 0 24px; font-size: 1.25rem; font-weight: 700; color: var(--gold); margin-bottom: 32px; }
.sidebar-brand span { color: #fff; }
.sidebar-nav a { display: flex; align-items: center; gap: 12px; padding: 12px 24px; color: #94a3b8; text-decoration: none; font-size: 0.9rem; transition: 0.2s; }
.sidebar-nav a:hover, .sidebar-nav a.active { background: rgba(255,255,255,0.05); color: #fff; border-right: 3px solid var(--gold); }
.sidebar-nav a i { width: 20px; text-align: center; }
.sidebar-user { position: absolute; bottom: 0; left: 0; right: 0; padding: 20px 24px; border-top: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.2); }
.sidebar-user .name { font-weight: 600; font-size: 0.9rem; }
.sidebar-user .role { font-size: 0.75rem; color: #94a3b8; }
.main-content { margin-left: var(--sidebar-width); padding: 32px; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.top-bar h1 { font-size: 1.75rem; font-weight: 700; color: var(--dark); margin: 0; }
.card { background: #fff; border-radius: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: none; margin-bottom: 24px; }
.card-header-custom { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
.card-header-custom h3 { margin: 0; font-size: 1.1rem; font-weight: 600; }
.card-body-custom { padding: 24px; }
.btn-primary-custom { background: var(--primary); color: #fff; border: none; padding: 10px 20px; border-radius: 10px; font-weight: 500; cursor: pointer; }
.btn-primary-custom:hover { background: #1d4ed8; }
.btn-gold { background: var(--gold); color: #fff; border: none; padding: 10px 20px; border-radius: 10px; font-weight: 500; cursor: pointer; }
.btn-gold:hover { background: #b8943f; }
.btn-danger-custom { background: #ef4444; color: #fff; border: none; padding: 8px 16px; border-radius: 8px; font-weight: 500; cursor: pointer; }
.btn-danger-custom:hover { background: #dc2626; }
.btn-sm { padding: 6px 12px; font-size: 0.8rem; }
.table th { font-weight: 600; color: #64748b; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #e2e8f0; }
.table td { vertical-align: middle; }
.badge { padding: 4px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 600; }
.badge-admin { background: #fef3c7; color: #92400e; }
.badge-user { background: #dbeafe; color: #1e40af; }
.modal-backdrop-custom { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 200; display: none; align-items: center; justify-content: center; }
.modal-backdrop-custom.show { display: flex; }
.modal-custom { background: #fff; border-radius: 16px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; }
.modal-header-custom { padding: 20px 24px; border-bottom: 1px solid #e2e8f0; display: flex; justify-content: space-between; align-items: center; }
.modal-header-custom h3 { margin: 0; font-size: 1.1rem; }
.modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #64748b; }
.modal-body-custom { padding: 24px; }
.modal-body-custom .form-label { font-weight: 500; font-size: 0.85rem; }
.modal-body-custom .form-control { border-radius: 10px; padding: 10px 14px; }
.modal-footer-custom { padding: 16px 24px; border-top: 1px solid #e2e8f0; display: flex; justify-content: flex-end; gap: 12px; }
.profile-section { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.profile-avatar { width: 56px; height: 56px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; }
.profile-info h4 { margin: 0; font-size: 1rem; }
.profile-info p { margin: 0; color: #64748b; font-size: 0.85rem; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 32px; }
.stat-card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.stat-card .stat-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 1.25rem; margin-bottom: 12px; }
.stat-card .stat-icon.blue { background: #dbeafe; color: #2563eb; }
.stat-card .stat-icon.gold { background: #fef3c7; color: #c9a84c; }
.stat-card .stat-icon.green { background: #dcfce7; color: #16a34a; }
.stat-card .stat-value { font-size: 1.75rem; font-weight: 700; color: var(--dark); }
.stat-card .stat-label { font-size: 0.8rem; color: #64748b; }
.toast-container { position: fixed; top: 24px; right: 24px; z-index: 999; }
.toast { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.15); margin-bottom: 12px; display: flex; align-items: center; gap: 12px; animation: slideIn 0.3s ease; min-width: 300px; }
.toast.success { border-left: 4px solid #16a34a; }
.toast.error { border-left: 4px solid #ef4444; }
.toast .toast-icon { font-size: 1.25rem; }
.toast.success .toast-icon { color: #16a34a; }
.toast.error .toast-icon { color: #ef4444; }
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.loading-spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-radius: 50%; border-top-color: #fff; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 768px) {
.sidebar { display: none; }
.main-content { margin-left: 0; }
}
</style>
</head>
<body>
<div id="login-page">
<div class="login-card">
<div class="login-logo">HOTEL <span>777</span></div>
<h2>Панель администратора</h2>
<p class="subtitle">Войдите для управления системой</p>
<form id="loginForm">
<div class="mb-3">
<label class="form-label">Логин</label>
<input type="text" class="form-control" id="loginInput" required placeholder="Введите логин">
</div>
<div class="mb-4">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" id="passwordInput" required placeholder="Введите пароль">
</div>
<button type="submit" class="login-btn" id="loginBtn">Войти</button>
<div class="login-error" id="loginError"></div>
</form>
</div>
</div>
<div id="admin-panel">
<div class="sidebar">
<div class="sidebar-brand">HOTEL <span>777</span></div>
<nav class="sidebar-nav">
<a href="#" class="active" data-tab="dashboard"><i class="fas fa-chart-pie"></i> Дашборд</a>
<a href="#" data-tab="users"><i class="fas fa-users"></i> Пользователи</a>
<a href="#" data-tab="bookings"><i class="fas fa-calendar-check"></i> Бронирования</a>
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
</nav>
<div class="sidebar-user">
<div class="name" id="sidebarUserName"></div>
<div class="role" id="sidebarUserRole"></div>
</div>
</div>
<div class="main-content">
<div class="toast-container" id="toastContainer"></div>
<div id="tab-dashboard" class="tab-content active">
<div class="top-bar">
<h1>Дашборд</h1>
<button class="btn-danger-custom btn-sm" onclick="logout()"><i class="fas fa-sign-out-alt"></i> Выйти</button>
</div>
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon blue"><i class="fas fa-users"></i></div>
<div class="stat-value" id="statUsers"></div>
<div class="stat-label">Пользователей</div>
</div>
<div class="stat-card">
<div class="stat-icon gold"><i class="fas fa-calendar-check"></i></div>
<div class="stat-value" id="statBookings"></div>
<div class="stat-label">Бронирований</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fas fa-shield-alt"></i></div>
<div class="stat-value" id="statAdmins"></div>
<div class="stat-label">Администраторов</div>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Последние бронирования</h3></div>
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Имя</th><th>Телефон</th><th>Заезд</th><th>Выезд</th><th>Дата</th></tr></thead>
<tbody id="recentBookings"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-users" class="tab-content">
<div class="top-bar">
<h1>Пользователи</h1>
<button class="btn-primary-custom admin-only" onclick="showUserModal()"><i class="fas fa-plus"></i> Добавить</button>
</div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Логин</th><th>ФИО</th><th>Email</th><th>Роль</th><th>Создан</th><th class="admin-only">Действия</th></tr></thead>
<tbody id="usersTable"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-bookings" class="tab-content">
<div class="top-bar"><h1>Бронирования</h1></div>
<div class="card">
<div class="card-body-custom">
<table class="table">
<thead><tr><th>Имя</th><th>Телефон</th><th>Взрослые</th><th>Дети</th><th>Заезд</th><th>Выезд</th><th>Пожелания</th><th>Создано</th></tr></thead>
<tbody id="allBookings"></tbody>
</table>
</div>
</div>
</div>
<div id="tab-profile" class="tab-content">
<div class="top-bar"><h1>Мой профиль</h1></div>
<div class="card">
<div class="card-body-custom">
<div class="profile-section">
<div class="profile-avatar" id="profileAvatar"></div>
<div class="profile-info">
<h4 id="profileName"></h4>
<p id="profileLogin"></p>
</div>
</div>
<form id="profileForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">ФИО</label>
<input type="text" class="form-control" id="profileFullName" placeholder="Иванов Иван Иванович">
</div>
<div class="col-md-6">
<label class="form-label">Email для уведомлений</label>
<input type="email" class="form-control" id="profileEmail" placeholder="email@example.com">
</div>
</div>
<button type="submit" class="btn-primary-custom mt-3"><i class="fas fa-save"></i> Сохранить</button>
</form>
</div>
</div>
<div class="card">
<div class="card-header-custom"><h3>Сменить пароль</h3></div>
<div class="card-body-custom">
<form id="passwordForm">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Текущий пароль</label>
<input type="password" class="form-control" id="currentPassword" required>
</div>
<div class="col-md-4">
<label class="form-label">Новый пароль</label>
<input type="password" class="form-control" id="newPassword" required minlength="4">
</div>
<div class="col-md-4">
<label class="form-label">Повторите пароль</label>
<input type="password" class="form-control" id="confirmPassword" required minlength="4">
</div>
</div>
<button type="submit" class="btn-gold mt-3"><i class="fas fa-key"></i> Сменить пароль</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="modal-backdrop-custom" id="userModal">
<div class="modal-custom">
<div class="modal-header-custom">
<h3 id="userModalTitle">Добавить пользователя</h3>
<button class="modal-close" onclick="hideUserModal()">&times;</button>
</div>
<form id="userForm">
<div class="modal-body-custom">
<input type="hidden" id="editUserId">
<div class="mb-3">
<label class="form-label">Логин *</label>
<input type="text" class="form-control" id="userLogin" required>
</div>
<div class="mb-3">
<label class="form-label">Пароль <span id="passwordHint">*</span></label>
<input type="password" class="form-control" id="userPassword" minlength="4">
</div>
<div class="mb-3">
<label class="form-label">ФИО</label>
<input type="text" class="form-control" id="userFullName" placeholder="Иванов Иван Иванович">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="userEmail" placeholder="email@example.com">
</div>
<div class="mb-3 admin-only-field">
<label class="form-label">Роль</label>
<select class="form-control" id="userRole">
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-secondary btn-sm" onclick="hideUserModal()">Отмена</button>
<button type="submit" class="btn-primary-custom btn-sm">Сохранить</button>
</div>
</form>
</div>
</div>
<script>
const API = '';
let token = localStorage.getItem('token');
let currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');
function getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }
function showToast(msg, type = 'success') {
const c = document.getElementById('toastContainer');
const t = document.createElement('div');
t.className = 'toast ' + type;
t.innerHTML = '<span class="toast-icon"><i class="fas fa-' + (type === 'success' ? 'check-circle' : 'exclamation-circle') + '"></i></span><span>' + msg + '</span>';
c.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
async function api(url, opts = {}) {
const res = await fetch(API + url, { ...opts, headers: { ...getHeaders(), ...opts.headers } });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
return data;
}
function initTabs() {
document.querySelectorAll('.sidebar-nav a').forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const tab = a.dataset.tab;
document.querySelectorAll('.sidebar-nav a').forEach(x => x.classList.remove('active'));
a.classList.add('active');
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
document.getElementById('tab-' + tab).classList.add('active');
if (tab === 'dashboard') loadDashboard();
if (tab === 'users') loadUsers();
if (tab === 'bookings') loadBookings();
if (tab === 'profile') loadProfile();
});
});
}
function updateUI() {
const isAdmin = currentUser && currentUser.role === 'admin';
document.querySelectorAll('.admin-only, .admin-only-field').forEach(el => {
el.style.display = isAdmin ? '' : 'none';
});
document.getElementById('sidebarUserName').textContent = currentUser.full_name || currentUser.login;
document.getElementById('sidebarUserRole').textContent = currentUser.role === 'admin' ? 'Администратор' : 'Пользователь';
}
async function checkAuth() {
if (!token || !currentUser) return showLogin();
try {
const users = await api('/api/admin/users');
const me = users.find(u => u.id === currentUser.id);
if (me) { currentUser = me; localStorage.setItem('currentUser', JSON.stringify(me)); showAdmin(); }
else showLogin();
} catch (e) { showLogin(); }
}
function showLogin() {
document.getElementById('login-page').style.display = 'flex';
document.getElementById('admin-panel').style.display = 'none';
}
function showAdmin() {
document.getElementById('login-page').style.display = 'none';
document.getElementById('admin-panel').style.display = 'block';
updateUI();
loadDashboard();
initTabs();
}
function logout() {
token = null; currentUser = null;
localStorage.removeItem('token'); localStorage.removeItem('currentUser');
showLogin();
}
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
const btn = document.getElementById('loginBtn');
btn.innerHTML = '<span class="loading-spinner"></span>';
try {
const data = await fetch(API + '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: document.getElementById('loginInput').value, password: document.getElementById('passwordInput').value })
}).then(r => r.json());
if (data.error) throw new Error(data.error);
token = data.token; currentUser = data.user;
localStorage.setItem('token', token); localStorage.setItem('currentUser', JSON.stringify(currentUser));
showAdmin();
} catch (err) {
const el = document.getElementById('loginError'); el.textContent = err.message; el.style.display = 'block';
}
btn.innerHTML = 'Войти';
});
async function loadDashboard() {
try {
const users = await api('/api/admin/users');
document.getElementById('statUsers').textContent = users.length;
document.getElementById('statAdmins').textContent = users.filter(u => u.role === 'admin').length;
document.getElementById('statBookings').textContent = '—';
} catch(e) {}
try {
const bookings = await fetch(API + '/api/bookings', { headers: { 'X-API-Key': (await fetch('/api/bookings').then(() => '')).catch(() => '') } }).catch(() => null);
} catch(e) {}
document.getElementById('recentBookings').innerHTML = '<tr><td colspan="5" class="text-center text-muted">Данные бронирований доступны через API-ключ</td></tr>';
}
async function loadUsers() {
try {
const users = await api('/api/admin/users');
const tbody = document.getElementById('usersTable');
tbody.innerHTML = users.map(u => '<tr>' +
'<td><strong>' + esc(u.login) + '</strong></td>' +
'<td>' + esc(u.full_name || '—') + '</td>' +
'<td>' + esc(u.email || '—') + '</td>' +
'<td><span class="badge ' + (u.role === 'admin' ? 'badge-admin' : 'badge-user') + '">' + (u.role === 'admin' ? 'Админ' : 'Пользователь') + '</span></td>' +
'<td>' + esc(u.created_at || '—') + '</td>' +
'<td class="admin-only">' +
'<button class="btn-primary-custom btn-sm me-1" onclick="showUserModal(' + u.id + ')"><i class="fas fa-edit"></i></button>' +
'<button class="btn-danger-custom btn-sm" onclick="deleteUser(' + u.id + ')"><i class="fas fa-trash"></i></button>' +
'</td></tr>').join('');
updateUI();
} catch(err) { showToast(err.message, 'error'); }
}
async function loadBookings() {
try {
const rows = await api('/api/bookings');
document.getElementById('allBookings').innerHTML = rows.map(r => '<tr>' +
'<td>' + esc(r.name) + '</td><td>' + esc(r.phone) + '</td><td>' + r.adults + '</td><td>' + r.children + '</td>' +
'<td>' + esc(r.checkin_date) + '</td><td>' + esc(r.checkout_date) + '</td><td>' + esc(r.wishes || '—') + '</td>' +
'<td>' + esc(r.created_at) + '</td></tr>').join('');
document.getElementById('statBookings').textContent = rows.length;
} catch(err) { showToast('Нет доступа к бронированиям', 'error'); }
}
function loadProfile() {
document.getElementById('profileFullName').value = currentUser.full_name || '';
document.getElementById('profileEmail').value = currentUser.email || '';
document.getElementById('profileName').textContent = currentUser.full_name || currentUser.login;
document.getElementById('profileLogin').textContent = '@' + currentUser.login;
document.getElementById('profileAvatar').textContent = (currentUser.full_name || currentUser.login).charAt(0).toUpperCase();
}
document.getElementById('profileForm').addEventListener('submit', async e => {
e.preventDefault();
try {
const data = await api('/api/auth/me', {
method: 'PUT',
body: JSON.stringify({ full_name: document.getElementById('profileFullName').value, email: document.getElementById('profileEmail').value })
});
currentUser = data.user; localStorage.setItem('currentUser', JSON.stringify(currentUser));
loadProfile(); updateUI(); showToast('Профиль обновлён');
} catch(err) { showToast(err.message, 'error'); }
});
document.getElementById('passwordForm').addEventListener('submit', async e => {
e.preventDefault();
const np = document.getElementById('newPassword').value;
if (np !== document.getElementById('confirmPassword').value) { showToast('Пароли не совпадают', 'error'); return; }
try {
await api('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ current_password: document.getElementById('currentPassword').value, new_password: np })
});
showToast('Пароль изменён'); e.target.reset();
} catch(err) { showToast(err.message, 'error'); }
});
function showUserModal(id) {
document.getElementById('userModal').classList.add('show');
document.getElementById('userForm').reset();
document.getElementById('editUserId').value = '';
document.getElementById('userModalTitle').textContent = 'Добавить пользователя';
document.getElementById('userLogin').disabled = false;
document.getElementById('passwordHint').textContent = '*';
document.getElementById('userPassword').required = true;
if (id) {
api('/api/admin/users').then(users => {
const u = users.find(x => x.id === id);
if (u) {
document.getElementById('editUserId').value = u.id;
document.getElementById('userLogin').value = u.login;
document.getElementById('userLogin').disabled = true;
document.getElementById('userFullName').value = u.full_name || '';
document.getElementById('userEmail').value = u.email || '';
document.getElementById('userRole').value = u.role;
document.getElementById('userModalTitle').textContent = 'Редактировать пользователя';
document.getElementById('passwordHint').textContent = '(оставьте пустым, если не меняете)';
document.getElementById('userPassword').required = false;
}
});
}
}
function hideUserModal() { document.getElementById('userModal').classList.remove('show'); }
document.getElementById('userForm').addEventListener('submit', async e => {
e.preventDefault();
const id = document.getElementById('editUserId').value;
const body = {
login: document.getElementById('userLogin').value,
full_name: document.getElementById('userFullName').value,
email: document.getElementById('userEmail').value,
role: document.getElementById('userRole').value
};
const pw = document.getElementById('userPassword').value;
if (pw) body.password = pw;
try {
if (id) {
await api('/api/admin/users/' + id, { method: 'PUT', body: JSON.stringify(body) });
showToast('Пользователь обновлён');
} else {
if (!pw) { showToast('Пароль обязателен', 'error'); return; }
await api('/api/admin/users', { method: 'POST', body: JSON.stringify(body) });
showToast('Пользователь создан');
}
hideUserModal(); loadUsers();
} catch(err) { showToast(err.message, 'error'); }
});
async function deleteUser(id) {
if (!confirm('Удалить пользователя?')) return;
try { await api('/api/admin/users/' + id, { method: 'DELETE' }); showToast('Пользователь удалён'); loadUsers(); }
catch(err) { showToast(err.message, 'error'); }
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
checkAuth();
</script>
</body>
</html>

183
server.js
View File

@@ -3,21 +3,25 @@ const path = require('path');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const sharp = require('sharp');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
const API_KEY = process.env.HOTEL777KEY;
const ADMIN_LOGIN = process.env.ADMIN_LOGIN;
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production';
if (!API_KEY) {
console.error('FATAL: HOTEL777KEY environment variable not set');
process.exit(1);
}
// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// Ensure data directory and database
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir);
const dbPath = path.join(dataDir, 'bookings.db');
@@ -43,7 +47,58 @@ db.run(`ALTER TABLE bookings ADD COLUMN wishes TEXT`, (err) => {
}
});
// Image conversion (automatically convert JPEG/PNG to WebP)
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
full_name TEXT,
email TEXT,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, (err) => {
if (err) console.error('Users table creation error:', err);
else syncAdmin();
});
function syncAdmin() {
if (!ADMIN_LOGIN || !ADMIN_PASSWORD) {
console.warn('WARNING: ADMIN_LOGIN or ADMIN_PASSWORD not set, skipping admin sync');
return;
}
const hash = bcrypt.hashSync(ADMIN_PASSWORD, 10);
db.get(`SELECT id, role FROM users WHERE login = ?`, [ADMIN_LOGIN], (err, row) => {
if (err) { console.error('Admin sync error:', err); return; }
if (row) {
db.run(`UPDATE users SET password_hash = ?, role = 'admin' WHERE login = ?`, [hash, ADMIN_LOGIN], (err) => {
if (err) console.error('Admin update error:', err);
else console.log(`✅ Superadmin "${ADMIN_LOGIN}" updated from .env`);
});
} else {
db.run(`INSERT INTO users (login, password_hash, full_name, email, role) VALUES (?, ?, 'Администратор', NULL, 'admin')`,
[ADMIN_LOGIN, hash], (err) => {
if (err) console.error('Admin creation error:', err);
else console.log(`✅ Superadmin "${ADMIN_LOGIN}" created from .env`);
});
}
});
}
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: 'Invalid or expired token' });
req.user = user;
next();
});
}
function requireAdmin(req, res, next) {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
next();
}
async function convertImages() {
const imgDir = path.join(__dirname, 'public', 'img');
if (!fs.existsSync(imgDir)) {
@@ -70,7 +125,6 @@ async function convertImages() {
}
}
// API: POST /api/bookings сохранить новую заявку
app.post('/api/bookings', (req, res) => {
const { name, phone, adults, children, checkin, checkout, wishes } = req.body;
if (!name || !phone || !adults || !checkin || !checkout) {
@@ -88,7 +142,6 @@ app.post('/api/bookings', (req, res) => {
stmt.finalize();
});
// API: GET /api/bookings получить список всех заявок (требуется API-ключ)
app.get('/api/bookings', (req, res) => {
const providedKey = req.headers['x-api-key'];
if (!providedKey || providedKey !== API_KEY) {
@@ -104,15 +157,129 @@ app.get('/api/bookings', (req, res) => {
});
});
// Serve frontend
app.post('/api/auth/login', (req, res) => {
const { login, password } = req.body;
if (!login || !password) return res.status(400).json({ error: 'Login and password required' });
db.get(`SELECT id, login, password_hash, full_name, email, role FROM users WHERE login = ?`, [login], (err, user) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const match = bcrypt.compareSync(password, user.password_hash);
if (!match) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ id: user.id, login: user.login, role: user.role }, JWT_SECRET, { expiresIn: '24h' });
res.json({
token,
user: { id: user.id, login: user.login, full_name: user.full_name, email: user.email, role: user.role }
});
});
});
app.put('/api/auth/me', authenticateToken, (req, res) => {
const { full_name, email } = req.body;
db.run(`UPDATE users SET full_name = COALESCE(?, full_name), email = COALESCE(?, email) WHERE id = ?`,
[full_name || null, email || null, req.user.id], function(err) {
if (err) return res.status(500).json({ error: 'Database error' });
db.get(`SELECT id, login, full_name, email, role FROM users WHERE id = ?`, [req.user.id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Profile updated', user: row });
});
});
});
app.post('/api/auth/change-password', authenticateToken, (req, res) => {
const { current_password, new_password } = req.body;
if (!current_password || !new_password) return res.status(400).json({ error: 'Current and new password required' });
if (new_password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' });
db.get(`SELECT password_hash FROM users WHERE id = ?`, [req.user.id], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'User not found' });
const match = bcrypt.compareSync(current_password, row.password_hash);
if (!match) return res.status(401).json({ error: 'Current password is incorrect' });
const hash = bcrypt.hashSync(new_password, 10);
db.run(`UPDATE users SET password_hash = ? WHERE id = ?`, [hash, req.user.id], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'Password changed successfully' });
});
});
});
app.get('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
db.all(`SELECT id, login, full_name, email, role, created_at FROM users ORDER BY created_at DESC`, [], (err, rows) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json(rows);
});
});
app.post('/api/admin/users', authenticateToken, requireAdmin, (req, res) => {
const { login, password, full_name, email, role } = req.body;
if (!login || !password) return res.status(400).json({ error: 'Login and password required' });
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user"' });
if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' });
const hash = bcrypt.hashSync(password, 10);
db.run(`INSERT INTO users (login, password_hash, full_name, email, role) VALUES (?, ?, ?, ?, ?)`,
[login, hash, full_name || null, email || null, role], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint')) return res.status(409).json({ error: 'Login already exists' });
return res.status(500).json({ error: 'Database error' });
}
db.get(`SELECT id, login, full_name, email, role, created_at FROM users WHERE id = ?`, [this.lastID], (err, row) => {
res.status(201).json({ message: 'User created', user: row });
});
});
});
app.put('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
const userId = parseInt(req.params.id);
const { full_name, email, role, password } = req.body;
if (role && !['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user"' });
db.get(`SELECT id FROM users WHERE id = ?`, [userId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'User not found' });
let fields = [];
let values = [];
if (full_name !== undefined) { fields.push('full_name = ?'); values.push(full_name || null); }
if (email !== undefined) { fields.push('email = ?'); values.push(email || null); }
if (role !== undefined) { fields.push('role = ?'); values.push(role); }
if (password) {
if (password.length < 4) return res.status(400).json({ error: 'Password must be at least 4 characters' });
fields.push('password_hash = ?');
values.push(bcrypt.hashSync(password, 10));
}
if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' });
values.push(userId);
db.run(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values, (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
db.get(`SELECT id, login, full_name, email, role, created_at FROM users WHERE id = ?`, [userId], (err, row) => {
res.json({ message: 'User updated', user: row });
});
});
});
});
app.delete('/api/admin/users/:id', authenticateToken, requireAdmin, (req, res) => {
const userId = parseInt(req.params.id);
if (userId === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' });
db.get(`SELECT login FROM users WHERE id = ?`, [userId], (err, row) => {
if (err) return res.status(500).json({ error: 'Database error' });
if (!row) return res.status(404).json({ error: 'User not found' });
if (row.login === ADMIN_LOGIN) return res.status(403).json({ error: 'Cannot delete superadmin defined in .env' });
db.run(`DELETE FROM users WHERE id = ?`, [userId], (err) => {
if (err) return res.status(500).json({ error: 'Database error' });
res.json({ message: 'User deleted' });
});
});
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Start server after image conversion
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'admin.html'));
});
convertImages().then(() => {
app.listen(PORT, () => {
console.log('✅ HOTEL777KEY is', API_KEY);
console.log(`✅ Hotel 777 server running on http://localhost:${PORT}`);
});
});
});