прибрался
This commit is contained in:
@@ -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
536
public/admin.html
Normal 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()">×</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
183
server.js
@@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user