site: add launcher-style auth (login/register modal, JWT localStorage) + cabinet with Сборки tab (list /packs, download /pack/{name}/zip with Bearer, pass check, manual Prologue intercept)

This commit is contained in:
SashegDev
2026-09-02 04:35:57 +00:00
parent 146a56cdff
commit 025a21d01f
3 changed files with 364 additions and 0 deletions
+277
View File
@@ -111,6 +111,24 @@
'preview.online': 'Онлайн',
'preview.install.title': 'Установка сборки…',
'preview.install.stage': 'Загрузка модов 130/130 · 1.8 ГБ',
'nav.login': 'Войти',
'nav.cabinet': 'Кабинет',
'nav.logout': 'Выйти',
'auth.title': 'Вход',
'auth.login': 'Вход',
'auth.register': 'Регистрация',
'auth.username': 'Логин',
'auth.password': 'Пароль',
'auth.confirm': 'Повтор пароля',
'auth.error': 'Ошибка',
'cabinet.title': 'Личный <span class="accent">кабинет.</span>',
'cabinet.sub': 'Ваш профиль и доступные сборки',
'cabinet.profile': 'Профиль',
'cabinet.packs': 'Сборки',
'cabinet.pass': 'Проходка',
'cabinet.available': 'Доступные сборки',
'cabinet.downloading': 'Скачивание...',
'cabinet.needPass': 'Нужна проходка',
'news.title': 'Что <span class="accent">нового.</span>',
'news.sub': 'Последние чейнджлоги и обновления команды Zern.',
'news.empty': 'Новостей пока нет.',
@@ -225,6 +243,24 @@
'preview.online': 'Online',
'preview.install.title': 'Installing pack…',
'preview.install.stage': 'Downloading mods 130/130 · 1.8 GB',
'nav.login': 'Sign in',
'nav.cabinet': 'Cabinet',
'nav.logout': 'Logout',
'auth.title': 'Sign in',
'auth.login': 'Sign in',
'auth.register': 'Sign up',
'auth.username': 'Username',
'auth.password': 'Password',
'auth.confirm': 'Confirm password',
'auth.error': 'Error',
'cabinet.title': 'Personal <span class="accent">cabinet.</span>',
'cabinet.sub': 'Your profile and available packs',
'cabinet.profile': 'Profile',
'cabinet.packs': 'Packs',
'cabinet.pass': 'Pass',
'cabinet.available': 'Available packs',
'cabinet.downloading': 'Downloading...',
'cabinet.needPass': 'Pass required',
'news.title': 'What\'s <span class="accent">new.</span>',
'news.sub': 'Latest changelogs and updates from the Zern team.',
'news.empty': 'No news yet.',
@@ -752,6 +788,244 @@
} catch(e) { /* keep static fallback */ }
}
// ====================== AUTH (site, launcher style) ======================
const AUTH_KEY = 'zern_auth';
let authMode = 'login'; // login | register
function getAuth() {
try { return JSON.parse(localStorage.getItem(AUTH_KEY) || 'null'); } catch(e) { return null; }
}
function setAuth(data) {
try { localStorage.setItem(AUTH_KEY, JSON.stringify(data)); } catch(e) {}
updateAuthUI();
}
function clearAuth() {
try { localStorage.removeItem(AUTH_KEY); } catch(e) {}
updateAuthUI();
}
function authHeaders() {
const a = getAuth();
return a && a.access_token ? { 'Authorization': 'Bearer ' + a.access_token } : {};
}
function updateAuthUI() {
const a = getAuth();
const btn = document.getElementById('auth-btn');
const menu = document.getElementById('user-menu');
const nameEl = document.getElementById('user-name');
if (a && a.username) {
if (btn) btn.classList.add('hidden');
if (menu) menu.classList.remove('hidden');
if (nameEl) nameEl.textContent = a.username;
} else {
if (btn) btn.classList.remove('hidden');
if (menu) menu.classList.add('hidden');
}
}
function showAuthModal(mode) {
authMode = mode || 'login';
const modal = document.getElementById('auth-modal');
const title = document.getElementById('auth-title');
const tabLogin = document.getElementById('auth-tab-login');
const tabReg = document.getElementById('auth-tab-register');
const confirmField = document.getElementById('auth-confirm-field');
const submit = document.getElementById('auth-submit');
const err = document.getElementById('auth-error');
if (err) { err.classList.add('hidden'); err.textContent=''; }
if (title) title.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login');
if (tabLogin) { tabLogin.style.background = authMode==='login' ? 'var(--accent)' : ''; tabLogin.style.color = authMode==='login' ? '#fff' : ''; }
if (tabReg) { tabReg.style.background = authMode==='register' ? 'var(--accent)' : ''; tabReg.style.color = authMode==='register' ? '#fff' : ''; }
if (confirmField) confirmField.classList.toggle('hidden', authMode!=='register');
if (submit) submit.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login');
if (modal) modal.classList.remove('hidden');
}
function hideAuthModal() {
const m = document.getElementById('auth-modal');
if (m) m.classList.add('hidden');
}
async function handleAuthSubmit() {
const u = document.getElementById('auth-username').value.trim();
const p = document.getElementById('auth-password').value;
const c = document.getElementById('auth-confirm') ? document.getElementById('auth-confirm').value : '';
const err = document.getElementById('auth-error');
const btn = document.getElementById('auth-submit');
if (!u || !p) { if (err){ err.textContent='Заполните поля'; err.classList.remove('hidden'); } return; }
if (authMode==='register' && p!==c) { if (err){ err.textContent='Пароли не совпадают'; err.classList.remove('hidden'); } return; }
if (btn) { btn.disabled=true; btn.textContent='...'; }
if (err) err.classList.add('hidden');
const endpoint = authMode==='register' ? '/auth/register' : '/auth/login';
try {
const r = await fetch(API + endpoint, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({username:u, password:p}) });
const data = await r.json();
if (!r.ok) throw new Error(data.detail || data.message || 'Ошибка');
// TokenResponse {access_token, refresh_token, username, uuid, role, role_name}
setAuth(data);
hideAuthModal();
// after login, refresh cabinet if open
if (!document.getElementById('cabinet').classList.contains('hidden')) loadCabinetPacks();
// also check pass
try { const pr = await fetch(API + '/auth/pass/my', { headers: authHeaders() }); const pd = await pr.json(); if (pd.has_active) { /* pass active */ } } catch(e){}
} catch(e) {
if (err){ err.textContent = e.message || 'Ошибка'; err.classList.remove('hidden'); }
} finally { if (btn) { btn.disabled=false; btn.textContent = authMode==='register' ? tr('auth.register') : tr('auth.login'); } }
}
async function handleLogout() {
const a = getAuth();
try { if (a) await fetch(API + '/auth/logout', { method:'POST', headers: authHeaders() }); } catch(e) {}
clearAuth();
// hide cabinet
document.getElementById('cabinet').classList.add('hidden');
}
async function loadCabinetProfile() {
const a = getAuth();
if (!a) return;
// try to get role/pass via /auth/pass/my and /admin/me or via token payload
const cabUser = document.getElementById('cab-username');
const cabAvatar = document.getElementById('cab-avatar');
const cabRole = document.getElementById('cab-role');
const cabUuid = document.getElementById('cab-uuid');
const cabPass = document.getElementById('cab-pass');
const cabPass2 = document.getElementById('cab-pass2');
if (cabUser) cabUser.textContent = a.username || '—';
if (cabAvatar) cabAvatar.textContent = (a.username||'Z')[0].toUpperCase();
if (cabUuid) cabUuid.textContent = a.uuid || '—';
if (cabRole) cabRole.textContent = a.role_name || ('Role '+ (a.role||0));
// pass check
try {
const r = await fetch(API + '/auth/pass/my', { headers: authHeaders() });
const d = await r.json();
const has = !!d.has_active;
if (cabPass) cabPass.textContent = has ? 'Активна' : 'Нет';
if (cabPass2) { cabPass2.textContent = has ? 'Активна' : 'Нет'; cabPass2.style.color = has ? 'var(--success)' : 'var(--error)'; }
} catch(e) {
if (cabPass) cabPass.textContent = '—';
}
}
async function loadCabinetPacks() {
const list = document.getElementById('cab-packs-list');
const count = document.getElementById('cab-packs-count');
const errEl = document.getElementById('cab-packs-error');
if (!list) return;
list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">Загрузка...</p>';
if (errEl) errEl.classList.add('hidden');
const a = getAuth();
if (!a) { list.innerHTML = '<p style="color:var(--text-muted)">Войдите, чтобы увидеть сборки</p>'; return; }
try {
const r = await fetch(API + '/packs', { headers: authHeaders() });
if (r.status===401 || r.status===403) throw new Error('Нужна проходка или вход');
if (!r.ok) throw new Error('Не удалось загрузить');
const data = await r.json();
const packs = data.packs || [];
if (count) count.textContent = packs.length + ' шт.';
if (!packs.length) { list.innerHTML = '<p style="color:var(--text-muted)">Нет доступных сборок</p>'; return; }
list.innerHTML = '';
packs.forEach(p=>{
const el = document.createElement('div');
el.style.cssText='display:flex;align-items:center;gap:12px;padding:12px;background:var(--bg-surface);border:1px solid var(--border);border-radius:10px';
el.innerHTML = `
<div style="width:36px;height:36px;border-radius:8px;background:rgba(233,69,96,0.12);display:flex;align-items:center;justify-content:center;color:var(--accent);flex-shrink:0">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px">${p.name} <span style="font-weight:400;color:var(--text-muted)">v${p.version||''}</span></div>
<div style="font-size:12px;color:var(--text-secondary)">${p.minecraft_version||''} · ${p.loader_type||''} ${p.loader_version||''} · ${p.files_count||0} файлов</div>
</div>
<button class="btn btn-primary" style="padding:8px 14px;font-size:13px;flex-shrink:0" data-pack="${p.name}">Скачать</button>
`;
const btn = el.querySelector('button');
btn.addEventListener('click', ()=> downloadPack(p.name, btn));
list.appendChild(el);
});
} catch(e) {
if (errEl){ errEl.textContent = e.message; errEl.classList.remove('hidden'); }
list.innerHTML = '';
}
}
async function downloadPack(name, btn) {
const orig = btn.textContent;
btn.disabled = true; btn.textContent = tr('cabinet.downloading');
try {
const r = await fetch(API + '/pack/' + encodeURIComponent(name) + '/zip', { headers: authHeaders() });
if (!r.ok) {
const t = await r.text();
let msg = t;
try { const j = JSON.parse(t); msg = j.detail || j.message || t; } catch(e){}
throw new Error(msg || ('HTTP '+r.status));
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name + '.zip';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
} catch(e) {
alert(e.message);
} finally { btn.disabled=false; btn.textContent=orig; }
}
function initAuth() {
updateAuthUI();
const authBtn = document.getElementById('auth-btn');
const cabinetBtn = document.getElementById('cabinet-btn');
const logoutBtn = document.getElementById('logout-btn');
const authModal = document.getElementById('auth-modal');
const authClose = document.getElementById('auth-close');
const tabLogin = document.getElementById('auth-tab-login');
const tabReg = document.getElementById('auth-tab-register');
const submit = document.getElementById('auth-submit');
const cabTabProfile = document.getElementById('cab-tab-profile');
const cabTabPacks = document.getElementById('cab-tab-packs');
if (authBtn) authBtn.addEventListener('click', ()=> showAuthModal('login'));
if (cabinetBtn) cabinetBtn.addEventListener('click', ()=>{
const cab = document.getElementById('cabinet');
if (cab) { cab.classList.remove('hidden'); cab.scrollIntoView({behavior:'smooth'}); loadCabinetProfile(); loadCabinetPacks(); }
});
if (logoutBtn) logoutBtn.addEventListener('click', handleLogout);
if (authClose) authClose.addEventListener('click', hideAuthModal);
if (authModal) authModal.addEventListener('click', e=>{ if(e.target===authModal) hideAuthModal(); });
if (tabLogin) tabLogin.addEventListener('click', ()=> showAuthModal('login'));
if (tabReg) tabReg.addEventListener('click', ()=> showAuthModal('register'));
if (submit) submit.addEventListener('click', handleAuthSubmit);
// enter key
['auth-username','auth-password','auth-confirm'].forEach(id=>{
const el=document.getElementById(id);
if(el) el.addEventListener('keydown', e=>{ if(e.key==='Enter') handleAuthSubmit(); });
});
if (cabTabProfile) cabTabProfile.addEventListener('click', ()=>{
document.getElementById('cabinet-profile').classList.remove('hidden');
document.getElementById('cabinet-packs').classList.add('hidden');
cabTabProfile.classList.add('btn-primary'); cabTabProfile.classList.remove('btn-ghost');
cabTabPacks.classList.remove('btn-primary'); cabTabPacks.classList.add('btn-ghost');
});
if (cabTabPacks) cabTabPacks.addEventListener('click', ()=>{
document.getElementById('cabinet-profile').classList.add('hidden');
document.getElementById('cabinet-packs').classList.remove('hidden');
cabTabPacks.classList.add('btn-primary'); cabTabPacks.classList.remove('btn-ghost');
cabTabProfile.classList.remove('btn-primary'); cabTabProfile.classList.add('btn-ghost');
loadCabinetPacks();
});
// also make manual Prologue button use auth download (override default link)
const manualBtn = document.querySelector('a[href="/pack/ZernPrologue/zip"]');
if (manualBtn) {
manualBtn.addEventListener('click', async (e)=>{
const a = getAuth();
if (!a) { e.preventDefault(); showAuthModal('login'); return; }
e.preventDefault();
// reuse downloadPack
const fakeBtn = { disabled:false, textContent: manualBtn.textContent };
// temporarily set text
const orig = manualBtn.innerHTML;
manualBtn.textContent = tr('cabinet.downloading');
try {
const r = await fetch(API + '/pack/ZernPrologue/zip', { headers: authHeaders() });
if (!r.ok) throw new Error((await r.text()).slice(0,120));
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a2 = document.createElement('a'); a2.href=url; a2.download='ZernPrologue.zip'; document.body.appendChild(a2); a2.click(); a2.remove(); URL.revokeObjectURL(url);
} catch(err){ alert(err.message); }
manualBtn.innerHTML = orig;
});
}
}
document.addEventListener('DOMContentLoaded', () => {
applyI18n();
@@ -770,5 +1044,8 @@
initParallax();
initReveal();
initYear();
initAuth();
// if already authed, preload cabinet data
if (getAuth()) loadCabinetProfile();
});
})();