feat(auth): Separate Login, Register, and Profile views with robust validations and SQL-safe backend
This commit is contained in:
+34
-10
@@ -337,7 +337,7 @@
|
|||||||
<!-- Authentication & Profile Modal -->
|
<!-- Authentication & Profile Modal -->
|
||||||
<div id="auth-modal" class="overlay hidden">
|
<div id="auth-modal" class="overlay hidden">
|
||||||
<div class="banner-card auth-card">
|
<div class="banner-card auth-card">
|
||||||
<!-- 1. Logged-in User Profile Dashboard View -->
|
<!-- 1. Logged-in User Profile View (ТОЛЬКО кнопка Выйти) -->
|
||||||
<div id="auth-logged-in-view" class="auth-view hidden">
|
<div id="auth-logged-in-view" class="auth-view hidden">
|
||||||
<h1 style="color: #81c784; font-size: 24px; margin-bottom: 6px;">👤 ЛИЧНЫЙ ПРОФИЛЬ</h1>
|
<h1 style="color: #81c784; font-size: 24px; margin-bottom: 6px;">👤 ЛИЧНЫЙ ПРОФИЛЬ</h1>
|
||||||
<div class="profile-header-card">
|
<div class="profile-header-card">
|
||||||
@@ -374,26 +374,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. Guest Login / Registration View -->
|
<!-- 2. Login View (Логин, Пароль, Войти, Регистрация, Остаться гостем) -->
|
||||||
<div id="auth-guest-view" class="auth-view">
|
<div id="auth-login-view" class="auth-view">
|
||||||
<h1 style="color: #ffeb3b; font-size: 24px; margin-bottom: 4px;">ВХОД В ПРОФИЛЬ</h1>
|
<h1 style="color: #ffeb3b; font-size: 24px; margin-bottom: 4px;">🔑 ВХОД В АККАУНТ</h1>
|
||||||
<p style="font-size: 13px; margin-bottom: 12px; color: #d7ccc8;">
|
<p style="font-size: 13px; margin-bottom: 12px; color: #d7ccc8;">
|
||||||
Войдите или зарегистрируйтесь, чтобы сохранять монеты, крафт и уровень кампании на сервере.
|
Войдите под своим логином для синхронизации сохранений с сервером.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="auth-form" onsubmit="return false;" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;">
|
<form id="auth-login-form" onsubmit="return false;" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;">
|
||||||
<input type="text" id="auth-username" class="auth-input" placeholder="Имя пользователя (логин)" maxlength="20" autocomplete="username">
|
<input type="text" id="auth-username" class="auth-input" placeholder="Логин (имя пользователя)" maxlength="20" autocomplete="username">
|
||||||
<input type="password" id="auth-password" class="auth-input" placeholder="Пароль" maxlength="30" autocomplete="current-password">
|
<input type="password" id="auth-password" class="auth-input" placeholder="Пароль" maxlength="30" autocomplete="current-password">
|
||||||
<div id="auth-error" class="auth-error hidden"></div>
|
<div id="auth-error" class="auth-error hidden"></div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 6px;">
|
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 4px;">
|
||||||
<button type="button" id="btn-auth-submit" class="btn btn-auth-submit">Войти / Регистрация</button>
|
<button type="button" id="btn-auth-submit" class="btn btn-auth-submit" style="font-size: 15px; font-weight: bold; padding: 10px;">🔑 Войти</button>
|
||||||
<button type="button" id="btn-auth-guest" class="btn btn-secondary">Играть как Гость</button>
|
<div style="display: flex; gap: 8px; justify-content: center;">
|
||||||
|
<button type="button" id="btn-auth-to-register" class="btn btn-secondary" style="flex: 1; font-size: 13px; padding: 8px;">📝 Регистрация</button>
|
||||||
|
<button type="button" id="btn-auth-guest" class="btn btn-secondary" style="flex: 1; font-size: 13px; padding: 8px;">👤 Остаться гостем</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button id="btn-auth-close" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
<button id="btn-auth-close" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Register View (Логин, Пароль, Зарегистрироваться, Назад к входу) -->
|
||||||
|
<div id="auth-register-view" class="auth-view hidden">
|
||||||
|
<h1 style="color: #69f0ae; font-size: 24px; margin-bottom: 4px;">📝 РЕГИСТРАЦИЯ</h1>
|
||||||
|
<p style="font-size: 13px; margin-bottom: 12px; color: #d7ccc8;">
|
||||||
|
Создайте профиль игрока для сохранения монет, крафта и кампании.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="auth-register-form" onsubmit="return false;" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px;">
|
||||||
|
<input type="text" id="auth-reg-username" class="auth-input" placeholder="Придумайте логин (от 3 символов)" maxlength="20" autocomplete="username">
|
||||||
|
<input type="password" id="auth-reg-password" class="auth-input" placeholder="Придумайте пароль (от 4 символов)" maxlength="30" autocomplete="new-password">
|
||||||
|
<div id="auth-reg-error" class="auth-error hidden"></div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 4px;">
|
||||||
|
<button type="button" id="btn-auth-do-register" class="btn btn-hero" style="background: linear-gradient(135deg, #2e7d32, #43a047); font-size: 15px; font-weight: bold; padding: 10px;">✨ Зарегистрироваться</button>
|
||||||
|
<button type="button" id="btn-auth-to-login" class="btn btn-secondary" style="font-size: 13px; padding: 8px;">◀ Уже есть аккаунт? Войти</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button id="btn-auth-close-reg" class="btn btn-secondary" style="font-size: 13px; padding: 6px 20px;">Закрыть</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+138
-44
@@ -206,7 +206,8 @@ window.GameEngine = class GameEngine {
|
|||||||
const btnAuth = document.getElementById('btn-auth-open');
|
const btnAuth = document.getElementById('btn-auth-open');
|
||||||
const btnLogoutNav = document.getElementById('btn-auth-logout-nav');
|
const btnLogoutNav = document.getElementById('btn-auth-logout-nav');
|
||||||
const loggedInView = document.getElementById('auth-logged-in-view');
|
const loggedInView = document.getElementById('auth-logged-in-view');
|
||||||
const guestView = document.getElementById('auth-guest-view');
|
const loginView = document.getElementById('auth-login-view') || document.getElementById('auth-guest-view');
|
||||||
|
const registerView = document.getElementById('auth-register-view');
|
||||||
const userTitle = document.getElementById('profile-user-title');
|
const userTitle = document.getElementById('profile-user-title');
|
||||||
const statCoins = document.getElementById('profile-stat-coins');
|
const statCoins = document.getElementById('profile-stat-coins');
|
||||||
const statLevel = document.getElementById('profile-stat-level');
|
const statLevel = document.getElementById('profile-stat-level');
|
||||||
@@ -226,13 +227,15 @@ window.GameEngine = class GameEngine {
|
|||||||
else btnLogoutNav.classList.remove('hidden');
|
else btnLogoutNav.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loggedInView && guestView) {
|
if (loggedInView) {
|
||||||
if (isGuest) {
|
if (isGuest) {
|
||||||
loggedInView.classList.add('hidden');
|
loggedInView.classList.add('hidden');
|
||||||
guestView.classList.remove('hidden');
|
if (loginView) loginView.classList.remove('hidden');
|
||||||
|
if (registerView) registerView.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
loggedInView.classList.remove('hidden');
|
loggedInView.classList.remove('hidden');
|
||||||
guestView.classList.add('hidden');
|
if (loginView) loginView.classList.add('hidden');
|
||||||
|
if (registerView) registerView.classList.add('hidden');
|
||||||
|
|
||||||
if (userTitle) userTitle.textContent = user;
|
if (userTitle) userTitle.textContent = user;
|
||||||
if (statCoins) statCoins.textContent = (uData.coins || (this.economy ? this.economy.totalCoins : 0)).toLocaleString('ru-RU');
|
if (statCoins) statCoins.textContent = (uData.coins || (this.economy ? this.economy.totalCoins : 0)).toLocaleString('ru-RU');
|
||||||
@@ -897,14 +900,37 @@ window.GameEngine = class GameEngine {
|
|||||||
const btnAuthOpen = document.getElementById('btn-auth-open');
|
const btnAuthOpen = document.getElementById('btn-auth-open');
|
||||||
const btnAuthClose = document.getElementById('btn-auth-close');
|
const btnAuthClose = document.getElementById('btn-auth-close');
|
||||||
const btnAuthCloseProfile = document.getElementById('btn-auth-close-profile');
|
const btnAuthCloseProfile = document.getElementById('btn-auth-close-profile');
|
||||||
const btnAuthSubmit = document.getElementById('btn-auth-submit');
|
const btnAuthCloseReg = document.getElementById('btn-auth-close-reg');
|
||||||
const btnAuthGuest = document.getElementById('btn-auth-guest');
|
|
||||||
const btnAuthLogout = document.getElementById('btn-auth-logout');
|
const loginView = document.getElementById('auth-login-view') || document.getElementById('auth-guest-view');
|
||||||
|
const registerView = document.getElementById('auth-register-view');
|
||||||
|
|
||||||
|
const btnAuthToRegister = document.getElementById('btn-auth-to-register');
|
||||||
|
const btnAuthToLogin = document.getElementById('btn-auth-to-login');
|
||||||
|
|
||||||
|
const btnAuthSubmit = document.getElementById('btn-auth-submit'); // Войти
|
||||||
|
const btnAuthDoRegister = document.getElementById('btn-auth-do-register'); // Зарегистрироваться
|
||||||
|
const btnAuthGuest = document.getElementById('btn-auth-guest'); // Остаться гостем
|
||||||
|
const btnAuthLogout = document.getElementById('btn-auth-logout'); // Выйти
|
||||||
const btnAuthLogoutNav = document.getElementById('btn-auth-logout-nav');
|
const btnAuthLogoutNav = document.getElementById('btn-auth-logout-nav');
|
||||||
|
|
||||||
const authUser = document.getElementById('auth-username');
|
const authUser = document.getElementById('auth-username');
|
||||||
const authPass = document.getElementById('auth-password');
|
const authPass = document.getElementById('auth-password');
|
||||||
const authError = document.getElementById('auth-error');
|
const authError = document.getElementById('auth-error');
|
||||||
|
|
||||||
|
const authRegUser = document.getElementById('auth-reg-username');
|
||||||
|
const authRegPass = document.getElementById('auth-reg-password');
|
||||||
|
const authRegError = document.getElementById('auth-reg-error');
|
||||||
|
|
||||||
|
const resetAuthForms = () => {
|
||||||
|
if (authUser) authUser.value = '';
|
||||||
|
if (authPass) authPass.value = '';
|
||||||
|
if (authError) { authError.textContent = ''; authError.classList.add('hidden'); }
|
||||||
|
if (authRegUser) authRegUser.value = '';
|
||||||
|
if (authRegPass) authRegPass.value = '';
|
||||||
|
if (authRegError) { authRegError.textContent = ''; authRegError.classList.add('hidden'); }
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (window.PvZAuth) {
|
if (window.PvZAuth) {
|
||||||
window.PvZAuth.logout();
|
window.PvZAuth.logout();
|
||||||
@@ -914,9 +940,7 @@ window.GameEngine = class GameEngine {
|
|||||||
this.updateShopUI();
|
this.updateShopUI();
|
||||||
}
|
}
|
||||||
if (authModal) authModal.classList.add('hidden');
|
if (authModal) authModal.classList.add('hidden');
|
||||||
if (authUser) authUser.value = '';
|
resetAuthForms();
|
||||||
if (authPass) authPass.value = '';
|
|
||||||
if (authError) authError.classList.add('hidden');
|
|
||||||
if (window.PvZAudio) window.PvZAudio.playCoin();
|
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -929,15 +953,20 @@ window.GameEngine = class GameEngine {
|
|||||||
|
|
||||||
if (btnAuthOpen && authModal) {
|
if (btnAuthOpen && authModal) {
|
||||||
btnAuthOpen.addEventListener('click', () => {
|
btnAuthOpen.addEventListener('click', () => {
|
||||||
if (authError) authError.classList.add('hidden');
|
resetAuthForms();
|
||||||
this.updateProfileUI();
|
this.updateProfileUI();
|
||||||
authModal.classList.remove('hidden');
|
authModal.classList.remove('hidden');
|
||||||
if (authUser && window.PvZAuth && window.PvZAuth.isGuest()) authUser.focus();
|
if (authUser && window.PvZAuth && window.PvZAuth.isGuest()) {
|
||||||
|
setTimeout(() => authUser.focus(), 50);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modal Close buttons
|
||||||
if (btnAuthClose && authModal) {
|
if (btnAuthClose && authModal) {
|
||||||
btnAuthClose.addEventListener('click', () => {
|
btnAuthClose.addEventListener('click', () => {
|
||||||
authModal.classList.add('hidden');
|
authModal.classList.add('hidden');
|
||||||
|
resetAuthForms();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (btnAuthCloseProfile && authModal) {
|
if (btnAuthCloseProfile && authModal) {
|
||||||
@@ -945,61 +974,80 @@ window.GameEngine = class GameEngine {
|
|||||||
authModal.classList.add('hidden');
|
authModal.classList.add('hidden');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (btnAuthGuest && authModal) {
|
if (btnAuthCloseReg && authModal) {
|
||||||
btnAuthGuest.addEventListener('click', () => {
|
btnAuthCloseReg.addEventListener('click', () => {
|
||||||
handleLogout();
|
authModal.classList.add('hidden');
|
||||||
|
resetAuthForms();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Switch between Login and Register views
|
||||||
|
if (btnAuthToRegister && loginView && registerView) {
|
||||||
|
btnAuthToRegister.addEventListener('click', () => {
|
||||||
|
resetAuthForms();
|
||||||
|
loginView.classList.add('hidden');
|
||||||
|
registerView.classList.remove('hidden');
|
||||||
|
if (authRegUser) setTimeout(() => authRegUser.focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnAuthToLogin && loginView && registerView) {
|
||||||
|
btnAuthToLogin.addEventListener('click', () => {
|
||||||
|
resetAuthForms();
|
||||||
|
registerView.classList.add('hidden');
|
||||||
|
loginView.classList.remove('hidden');
|
||||||
|
if (authUser) setTimeout(() => authUser.focus(), 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stay as Guest button
|
||||||
|
if (btnAuthGuest && authModal) {
|
||||||
|
btnAuthGuest.addEventListener('click', () => {
|
||||||
|
if (window.PvZAuth) window.PvZAuth.loginAsGuest();
|
||||||
|
this.loadUserData();
|
||||||
|
this.updateProfileUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
authModal.classList.add('hidden');
|
||||||
|
resetAuthForms();
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playButtonClick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login Action (Войти)
|
||||||
if (btnAuthSubmit && authModal) {
|
if (btnAuthSubmit && authModal) {
|
||||||
btnAuthSubmit.addEventListener('click', () => {
|
btnAuthSubmit.addEventListener('click', async () => {
|
||||||
const u = authUser ? authUser.value.trim() : '';
|
const u = authUser ? authUser.value.trim() : '';
|
||||||
const p = authPass ? authPass.value : '';
|
const p = authPass ? authPass.value : '';
|
||||||
|
|
||||||
if (!u) {
|
if (!u) {
|
||||||
if (authError) { authError.textContent = 'Введите имя пользователя'; authError.classList.remove('hidden'); }
|
if (authError) { authError.textContent = 'Введите логин'; authError.classList.remove('hidden'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!p && u.toLowerCase() !== 'гость') {
|
if (!p) {
|
||||||
if (authError) { authError.textContent = 'Введите пароль'; authError.classList.remove('hidden'); }
|
if (authError) { authError.textContent = 'Введите пароль'; authError.classList.remove('hidden'); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (authError) authError.classList.add('hidden');
|
if (authError) authError.classList.add('hidden');
|
||||||
|
|
||||||
if (window.PvZAuth) {
|
if (window.PvZAuth) {
|
||||||
btnAuthSubmit.textContent = '⏳ Подключение...';
|
btnAuthSubmit.textContent = '⏳ Вход...';
|
||||||
btnAuthSubmit.disabled = true;
|
btnAuthSubmit.disabled = true;
|
||||||
|
|
||||||
const res = window.PvZAuth.login(u, p, (serverData, errMsg) => {
|
const res = await window.PvZAuth.login(u, p);
|
||||||
// Server response callback (async)
|
btnAuthSubmit.textContent = '🔑 Войти';
|
||||||
btnAuthSubmit.textContent = 'Войти / Регистрация';
|
btnAuthSubmit.disabled = false;
|
||||||
btnAuthSubmit.disabled = false;
|
|
||||||
if (errMsg) {
|
|
||||||
// Wrong password from server
|
|
||||||
if (authError) { authError.textContent = errMsg; authError.classList.remove('hidden'); }
|
|
||||||
// Restore guest state in UI
|
|
||||||
this.updateProfileUI();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (serverData) {
|
|
||||||
this.loadUserData();
|
|
||||||
this.updateProfileUI();
|
|
||||||
this.updateRecordDisplay();
|
|
||||||
this.updateShopUI();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.success) {
|
if (res && res.success) {
|
||||||
// Optimistic: close modal immediately (data loads async)
|
|
||||||
authModal.classList.add('hidden');
|
authModal.classList.add('hidden');
|
||||||
if (authUser) authUser.value = '';
|
resetAuthForms();
|
||||||
if (authPass) authPass.value = '';
|
|
||||||
this.loadUserData();
|
this.loadUserData();
|
||||||
this.updateProfileUI();
|
this.updateProfileUI();
|
||||||
this.updateRecordDisplay();
|
this.updateRecordDisplay();
|
||||||
|
this.updateShopUI();
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||||
} else {
|
} else {
|
||||||
btnAuthSubmit.textContent = 'Войти / Регистрация';
|
|
||||||
btnAuthSubmit.disabled = false;
|
|
||||||
if (authError) {
|
if (authError) {
|
||||||
authError.textContent = res.message || 'Ошибка входа';
|
authError.textContent = (res && res.message) ? res.message : 'Неверный логин или пароль';
|
||||||
authError.classList.remove('hidden');
|
authError.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1007,6 +1055,52 @@ window.GameEngine = class GameEngine {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register Action (Зарегистрироваться)
|
||||||
|
if (btnAuthDoRegister && authModal) {
|
||||||
|
btnAuthDoRegister.addEventListener('click', async () => {
|
||||||
|
const u = authRegUser ? authRegUser.value.trim() : '';
|
||||||
|
const p = authRegPass ? authRegPass.value : '';
|
||||||
|
|
||||||
|
if (!u) {
|
||||||
|
if (authRegError) { authRegError.textContent = 'Введите логин для регистрации'; authRegError.classList.remove('hidden'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (u.length < 3) {
|
||||||
|
if (authRegError) { authRegError.textContent = 'Логин должен содержать минимум 3 символа'; authRegError.classList.remove('hidden'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!p || p.length < 4) {
|
||||||
|
if (authRegError) { authRegError.textContent = 'Пароль должен содержать минимум 4 символа'; authRegError.classList.remove('hidden'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authRegError) authRegError.classList.add('hidden');
|
||||||
|
|
||||||
|
if (window.PvZAuth) {
|
||||||
|
btnAuthDoRegister.textContent = '⏳ Регистрация...';
|
||||||
|
btnAuthDoRegister.disabled = true;
|
||||||
|
|
||||||
|
const res = await window.PvZAuth.register(u, p);
|
||||||
|
btnAuthDoRegister.textContent = '✨ Зарегистрироваться';
|
||||||
|
btnAuthDoRegister.disabled = false;
|
||||||
|
|
||||||
|
if (res && res.success) {
|
||||||
|
authModal.classList.add('hidden');
|
||||||
|
resetAuthForms();
|
||||||
|
this.loadUserData();
|
||||||
|
this.updateProfileUI();
|
||||||
|
this.updateRecordDisplay();
|
||||||
|
this.updateShopUI();
|
||||||
|
if (window.PvZAudio) window.PvZAudio.playCoin();
|
||||||
|
} else {
|
||||||
|
if (authRegError) {
|
||||||
|
authRegError.textContent = (res && res.message) ? res.message : 'Ошибка при регистрации';
|
||||||
|
authRegError.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Shop Button
|
// Shop Button
|
||||||
const btnShop = document.getElementById('btn-shop');
|
const btnShop = document.getElementById('btn-shop');
|
||||||
|
|||||||
+122
-54
@@ -5,8 +5,9 @@ window.PvZAuth = (function () {
|
|||||||
const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
|
const LOCAL_DATA_KEY = 'pvz_local_data'; // local fallback cache
|
||||||
const API_BASE = '/api/auth';
|
const API_BASE = '/api/auth';
|
||||||
|
|
||||||
/* ─── Simple djb2 hash (same as before, for backward-compat) ─── */
|
/* ─── Password hashing (djb2 for lightweight fast client hashing) ─── */
|
||||||
function hashPassword(str) {
|
function hashPassword(str) {
|
||||||
|
if (!str) return '';
|
||||||
let h = 0;
|
let h = 0;
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
h = ((h << 5) - h) + str.charCodeAt(i);
|
h = ((h << 5) - h) + str.charCodeAt(i);
|
||||||
@@ -15,6 +16,29 @@ window.PvZAuth = (function () {
|
|||||||
return h.toString(16);
|
return h.toString(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Validation Helpers ─── */
|
||||||
|
function validateUsername(username) {
|
||||||
|
const u = (username || '').trim();
|
||||||
|
if (!u) return { valid: false, message: 'Введите имя пользователя' };
|
||||||
|
if (u.length < 3) return { valid: false, message: 'Логин должен содержать минимум 3 символа' };
|
||||||
|
if (u.length > 20) return { valid: false, message: 'Логин не должен превышать 20 символов' };
|
||||||
|
if (!/^[a-zA-Zа-яА-ЯёЁ0-9_-]+$/.test(u)) {
|
||||||
|
return { valid: false, message: 'Логин может содержать только буквы, цифры, "-" и "_"' };
|
||||||
|
}
|
||||||
|
const reserved = ['гость', 'guest', 'admin', 'null', 'undefined', 'system', 'anonymous'];
|
||||||
|
if (reserved.includes(u.toLowerCase())) {
|
||||||
|
return { valid: false, message: 'Это имя пользователя зарезервировано' };
|
||||||
|
}
|
||||||
|
return { valid: true, username: u };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePassword(password) {
|
||||||
|
const p = password || '';
|
||||||
|
if (!p) return { valid: false, message: 'Введите пароль' };
|
||||||
|
if (p.length < 4) return { valid: false, message: 'Пароль должен содержать не менее 4 символов' };
|
||||||
|
return { valid: true, password: p };
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Default blank profile ─── */
|
/* ─── Default blank profile ─── */
|
||||||
function getDefaultData() {
|
function getDefaultData() {
|
||||||
return {
|
return {
|
||||||
@@ -65,9 +89,10 @@ window.PvZAuth = (function () {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
return await resp.json();
|
const json = await resp.json();
|
||||||
|
return json;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, message: 'Сервер недоступен' };
|
return { success: false, message: 'Сервер авторизации недоступен' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +107,29 @@ window.PvZAuth = (function () {
|
|||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeUserData(serverData) {
|
||||||
|
const def = getDefaultData();
|
||||||
|
if (!serverData) return def;
|
||||||
|
return {
|
||||||
|
...def,
|
||||||
|
...serverData,
|
||||||
|
upgrades: { ...def.upgrades, ...(serverData.upgrades || {}) },
|
||||||
|
materials: { ...def.materials, ...(serverData.materials || {}) },
|
||||||
|
plantLevels: { ...def.plantLevels, ...(serverData.plantLevels || {}) },
|
||||||
|
perks: { ...def.perks, ...(serverData.perks || {}) },
|
||||||
|
zenGarden: serverData.zenGarden
|
||||||
|
? {
|
||||||
|
...def.zenGarden,
|
||||||
|
...serverData.zenGarden,
|
||||||
|
pots: serverData.zenGarden.pots || def.zenGarden.pots,
|
||||||
|
snail: { ...def.zenGarden.snail, ...(serverData.zenGarden.snail || {}) }
|
||||||
|
}
|
||||||
|
: def.zenGarden,
|
||||||
|
treeOfWisdom: serverData.treeOfWisdom || def.treeOfWisdom,
|
||||||
|
achievements: serverData.achievements || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/* ── Public API ── */
|
/* ── Public API ── */
|
||||||
|
|
||||||
@@ -95,64 +143,86 @@ window.PvZAuth = (function () {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getDefaultData,
|
getDefaultData,
|
||||||
|
validateUsername,
|
||||||
|
validatePassword,
|
||||||
|
apiPost,
|
||||||
|
scheduleSave,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login or auto-register.
|
* User Login
|
||||||
* Synchronous return with provisional localStorage data;
|
|
||||||
* server response loaded asynchronously and applied via callback.
|
|
||||||
*/
|
*/
|
||||||
login(username, password, onServerResponse = null) {
|
async login(username, password, callback = null) {
|
||||||
username = (username || '').trim();
|
const userVal = validateUsername(username);
|
||||||
if (!username) return { success: false, message: 'Введите имя пользователя' };
|
if (!userVal.valid) {
|
||||||
if (username.toLowerCase() === 'гость') {
|
if (typeof callback === 'function') callback(false, null, userVal.message);
|
||||||
this.loginAsGuest();
|
return { success: false, message: userVal.message };
|
||||||
return { success: true, user: 'Гость' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pwdHash = hashPassword(password);
|
const passVal = validatePassword(password);
|
||||||
|
if (!passVal.valid) {
|
||||||
|
if (typeof callback === 'function') callback(false, null, passVal.message);
|
||||||
|
return { success: false, message: passVal.message };
|
||||||
|
}
|
||||||
|
|
||||||
// Optimistically set local session
|
const cleanUser = userVal.username;
|
||||||
localStorage.setItem(CURRENT_USER_KEY, username);
|
const pwdHash = hashPassword(passVal.password);
|
||||||
_cachedData = getDefaultData();
|
|
||||||
localSave(_cachedData);
|
|
||||||
|
|
||||||
// Async server auth
|
const res = await apiPost('login', { username: cleanUser, passwordHash: pwdHash });
|
||||||
apiPost('login', { username, passwordHash: pwdHash }).then(res => {
|
|
||||||
if (res && res.success && res.data) {
|
if (res && res.success && res.data) {
|
||||||
// Merge defaults + server data
|
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
|
||||||
const def = getDefaultData();
|
_cachedData = mergeUserData(res.data);
|
||||||
_cachedData = {
|
localSave(_cachedData);
|
||||||
...def, ...res.data,
|
|
||||||
upgrades: { ...def.upgrades, ...(res.data.upgrades || {}) },
|
if (window.GameEngine && window._pvzEngine) {
|
||||||
materials: { ...def.materials, ...(res.data.materials || {}) },
|
window._pvzEngine.loadUserData();
|
||||||
plantLevels: { ...def.plantLevels, ...(res.data.plantLevels || {}) },
|
|
||||||
perks: { ...def.perks, ...(res.data.perks || {}) },
|
|
||||||
zenGarden: res.data.zenGarden
|
|
||||||
? { ...def.zenGarden, ...res.data.zenGarden,
|
|
||||||
pots: res.data.zenGarden.pots || def.zenGarden.pots,
|
|
||||||
snail: { ...def.zenGarden.snail, ...(res.data.zenGarden.snail || {}) }
|
|
||||||
}
|
|
||||||
: def.zenGarden,
|
|
||||||
treeOfWisdom: res.data.treeOfWisdom || def.treeOfWisdom,
|
|
||||||
achievements: res.data.achievements || {}
|
|
||||||
};
|
|
||||||
localSave(_cachedData);
|
|
||||||
if (typeof onServerResponse === 'function') onServerResponse(_cachedData);
|
|
||||||
// Notify game engine to reload profile
|
|
||||||
if (window.GameEngine && window._pvzEngine) {
|
|
||||||
window._pvzEngine.loadUserData();
|
|
||||||
}
|
|
||||||
} else if (res && !res.success) {
|
|
||||||
// Wrong password — kick back to guest
|
|
||||||
if (res.message && res.message.includes('пароль')) {
|
|
||||||
localStorage.setItem(CURRENT_USER_KEY, 'Гость');
|
|
||||||
_cachedData = null;
|
|
||||||
if (typeof onServerResponse === 'function') onServerResponse(null, res.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
if (typeof callback === 'function') callback(true, _cachedData, null);
|
||||||
|
return { success: true, username: cleanUser, data: _cachedData };
|
||||||
|
} else {
|
||||||
|
const errorMsg = (res && res.message) ? res.message : 'Неверный логин или пароль';
|
||||||
|
if (typeof callback === 'function') callback(false, null, errorMsg);
|
||||||
|
return { success: false, message: errorMsg };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
return { success: true, user: username, pending: true };
|
/**
|
||||||
|
* User Registration
|
||||||
|
*/
|
||||||
|
async register(username, password, callback = null) {
|
||||||
|
const userVal = validateUsername(username);
|
||||||
|
if (!userVal.valid) {
|
||||||
|
if (typeof callback === 'function') callback(false, null, userVal.message);
|
||||||
|
return { success: false, message: userVal.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const passVal = validatePassword(password);
|
||||||
|
if (!passVal.valid) {
|
||||||
|
if (typeof callback === 'function') callback(false, null, passVal.message);
|
||||||
|
return { success: false, message: passVal.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanUser = userVal.username;
|
||||||
|
const pwdHash = hashPassword(passVal.password);
|
||||||
|
const initData = getDefaultData();
|
||||||
|
|
||||||
|
const res = await apiPost('register', { username: cleanUser, passwordHash: pwdHash, initialData: initData });
|
||||||
|
|
||||||
|
if (res && res.success) {
|
||||||
|
localStorage.setItem(CURRENT_USER_KEY, cleanUser);
|
||||||
|
_cachedData = mergeUserData(res.data || initData);
|
||||||
|
localSave(_cachedData);
|
||||||
|
|
||||||
|
if (window.GameEngine && window._pvzEngine) {
|
||||||
|
window._pvzEngine.loadUserData();
|
||||||
|
}
|
||||||
|
if (typeof callback === 'function') callback(true, _cachedData, null);
|
||||||
|
return { success: true, username: cleanUser, data: _cachedData };
|
||||||
|
} else {
|
||||||
|
const errorMsg = (res && res.message) ? res.message : 'Ошибка при регистрации';
|
||||||
|
if (typeof callback === 'function') callback(false, null, errorMsg);
|
||||||
|
return { success: false, message: errorMsg };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loginAsGuest() {
|
loginAsGuest() {
|
||||||
@@ -163,7 +233,6 @@ window.PvZAuth = (function () {
|
|||||||
},
|
},
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
// Flush any pending save first
|
|
||||||
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
|
||||||
const user = this.getCurrentUser();
|
const user = this.getCurrentUser();
|
||||||
if (user !== 'Гость' && _cachedData) {
|
if (user !== 'Гость' && _cachedData) {
|
||||||
@@ -177,7 +246,6 @@ window.PvZAuth = (function () {
|
|||||||
|
|
||||||
if (_cachedData) return _cachedData;
|
if (_cachedData) return _cachedData;
|
||||||
|
|
||||||
// Try local cache (sync fallback while server loads)
|
|
||||||
const local = localLoad();
|
const local = localLoad();
|
||||||
if (local) {
|
if (local) {
|
||||||
_cachedData = local;
|
_cachedData = local;
|
||||||
|
|||||||
+78
-18
@@ -164,8 +164,53 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
|
self.wfile.write(json.dumps({"error": f"Invalid JSON: {e}"}).encode("utf-8"))
|
||||||
return
|
return
|
||||||
|
|
||||||
# 1. User Authentication (Login & Auto-Registration)
|
# 1a. User Login
|
||||||
if path in ["/api/auth/login", "/api/bugs/auth/login", "/api/auth/register"]:
|
if path in ["/api/auth/login", "/api/bugs/auth/login"]:
|
||||||
|
username = str(payload.get("username", "")).strip()
|
||||||
|
password_hash = str(payload.get("passwordHash", "")).strip()
|
||||||
|
|
||||||
|
if not username or not password_hash:
|
||||||
|
self.send_response(400)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
import re
|
||||||
|
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
|
||||||
|
self.send_response(400)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
user = db.get_user_by_username(username)
|
||||||
|
if not user:
|
||||||
|
self.send_response(404)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином не найден. Зарегистрируйтесь."}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
res = db.authenticate_user(username, password_hash)
|
||||||
|
if not res or not res.get("success"):
|
||||||
|
self.send_response(401)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный логин или пароль"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self._set_cors()
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1b. User Registration
|
||||||
|
if path in ["/api/auth/register", "/api/bugs/auth/register"]:
|
||||||
username = str(payload.get("username", "")).strip()
|
username = str(payload.get("username", "")).strip()
|
||||||
password_hash = str(payload.get("passwordHash", "")).strip()
|
password_hash = str(payload.get("passwordHash", "")).strip()
|
||||||
initial_data = payload.get("initialData")
|
initial_data = payload.get("initialData")
|
||||||
@@ -174,27 +219,42 @@ class FeedbackHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.send_response(400)
|
self.send_response(400)
|
||||||
self._set_cors()
|
self._set_cors()
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(json.dumps({"success": False, "message": "Имя пользователя и пароль обязательны"}).encode("utf-8"))
|
self.wfile.write(json.dumps({"success": False, "message": "Введите логин и пароль для регистрации"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
import re
|
||||||
|
if not re.match(r"^[a-zA-Zа-яА-ЯёЁ0-9_-]{3,20}$", username):
|
||||||
|
self.send_response(400)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"success": False, "message": "Логин может содержать только буквы, цифры, '-' и '_' (3-20 симв.)"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(password_hash) < 1:
|
||||||
|
self.send_response(400)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"success": False, "message": "Пароль не может быть пустым"}).encode("utf-8"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if username.lower() in ["гость", "guest", "admin", "null", "undefined", "system", "anonymous"]:
|
||||||
|
self.send_response(400)
|
||||||
|
self._set_cors()
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps({"success": False, "message": "Это имя пользователя зарезервировано"}).encode("utf-8"))
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if user exists
|
|
||||||
user = db.get_user_by_username(username)
|
user = db.get_user_by_username(username)
|
||||||
if user:
|
if user:
|
||||||
res = db.authenticate_user(username, password_hash)
|
self.send_response(409)
|
||||||
if not res or not res.get("success"):
|
self._set_cors()
|
||||||
self.send_response(401)
|
self.end_headers()
|
||||||
self._set_cors()
|
self.wfile.write(json.dumps({"success": False, "message": "Пользователь с таким логином уже существует"}).encode("utf-8"))
|
||||||
self.end_headers()
|
return
|
||||||
self.wfile.write(json.dumps(res or {"success": False, "message": "Неверный пароль"}).encode("utf-8"))
|
|
||||||
return
|
|
||||||
resp_json = res
|
|
||||||
else:
|
|
||||||
# Auto-register new user
|
|
||||||
res = db.register_user(username, password_hash, initial_data)
|
|
||||||
resp_json = res
|
|
||||||
|
|
||||||
data = json.dumps(resp_json, ensure_ascii=False).encode("utf-8")
|
res = db.register_user(username, password_hash, initial_data)
|
||||||
self.send_response(200)
|
data = json.dumps(res, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(200 if res.get("success") else 400)
|
||||||
self._set_cors()
|
self._set_cors()
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
self.send_header("Content-Length", str(len(data)))
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ def test_auth_system():
|
|||||||
"getCurrentUser",
|
"getCurrentUser",
|
||||||
"isGuest",
|
"isGuest",
|
||||||
"login",
|
"login",
|
||||||
|
"register",
|
||||||
"loginAsGuest",
|
"loginAsGuest",
|
||||||
"logout",
|
"logout",
|
||||||
"getUserData",
|
"getUserData",
|
||||||
@@ -193,6 +194,8 @@ def test_auth_system():
|
|||||||
"flushSave",
|
"flushSave",
|
||||||
"scheduleSave",
|
"scheduleSave",
|
||||||
"apiPost",
|
"apiPost",
|
||||||
|
"validateUsername",
|
||||||
|
"validatePassword"
|
||||||
]
|
]
|
||||||
for m in auth_methods:
|
for m in auth_methods:
|
||||||
if m in auth_code:
|
if m in auth_code:
|
||||||
@@ -205,6 +208,19 @@ def test_auth_system():
|
|||||||
record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL")
|
record("auth.js has debounced save (scheduleSave)", "PASS" if "scheduleSave" in auth_code else "FAIL")
|
||||||
record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL")
|
record("auth.js has flushSave for game-over sync", "PASS" if "flushSave" in auth_code else "FAIL")
|
||||||
|
|
||||||
|
# Check HTML auth views
|
||||||
|
index_html_path = os.path.join(GAME_DIR, "index.html")
|
||||||
|
with open(index_html_path, "r", encoding="utf-8") as f:
|
||||||
|
html_code = f.read()
|
||||||
|
|
||||||
|
record("Auth Login view present", "PASS" if 'id="auth-login-view"' in html_code else "FAIL")
|
||||||
|
record("Auth Register view present", "PASS" if 'id="auth-register-view"' in html_code else "FAIL")
|
||||||
|
record("Auth Logged-in view present", "PASS" if 'id="auth-logged-in-view"' in html_code else "FAIL")
|
||||||
|
record("Auth switch to register button present", "PASS" if 'id="btn-auth-to-register"' in html_code else "FAIL")
|
||||||
|
record("Auth do register button present", "PASS" if 'id="btn-auth-do-register"' in html_code else "FAIL")
|
||||||
|
record("Auth guest button present", "PASS" if 'id="btn-auth-guest"' in html_code else "FAIL")
|
||||||
|
record("Auth logout button present", "PASS" if 'id="btn-auth-logout"' in html_code else "FAIL")
|
||||||
|
|
||||||
# Server-side SQLite DB tests
|
# Server-side SQLite DB tests
|
||||||
print("\n--- 4b. SQLite DB & Auth API CRUD Tests ---")
|
print("\n--- 4b. SQLite DB & Auth API CRUD Tests ---")
|
||||||
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
sys.path.insert(0, os.path.join(BASE_DIR, "scripts"))
|
||||||
@@ -233,6 +249,10 @@ def test_auth_system():
|
|||||||
record("SQLite DB: reject wrong password", "PASS" if bad_auth and not bad_auth.get("success") else "FAIL",
|
record("SQLite DB: reject wrong password", "PASS" if bad_auth and not bad_auth.get("success") else "FAIL",
|
||||||
"Bad password was accepted!" if bad_auth and bad_auth.get("success") else "")
|
"Bad password was accepted!" if bad_auth and bad_auth.get("success") else "")
|
||||||
|
|
||||||
|
# Test duplicate registration rejection
|
||||||
|
dup_reg = pvz_db.register_user("kopri", "c63bc483")
|
||||||
|
record("SQLite DB: reject duplicate user registration", "PASS" if not dup_reg.get("success") else "FAIL")
|
||||||
|
|
||||||
# Test save profile (update newbie coins)
|
# Test save profile (update newbie coins)
|
||||||
save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500})
|
save_res = pvz_db.save_user_profile("newbie_test", {"coins": 999, "level": 3, "highScore": 2500})
|
||||||
record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL")
|
record("SQLite DB: save_user_profile() succeeds", "PASS" if save_res.get("success") else "FAIL")
|
||||||
|
|||||||
Reference in New Issue
Block a user