180 lines
6.3 KiB
JavaScript
180 lines
6.3 KiB
JavaScript
/* ── App bootstrap & routing ── */
|
||
|
||
const PAGES = {
|
||
tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
|
||
bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
|
||
categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
|
||
settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
|
||
status: { container: 'page-status', init: (el) => StatusPage.init(el) },
|
||
};
|
||
|
||
let activePage = null;
|
||
let currentUser = null;
|
||
|
||
// ── Auth gate ──────────────────────────────────────────────────────────────
|
||
|
||
async function boot() {
|
||
let res;
|
||
try { res = await fetch('/api/auth/me'); }
|
||
catch { location.href = '/login.html'; return; }
|
||
|
||
if (!res.ok) { location.href = '/login.html'; return; }
|
||
|
||
const data = await res.json();
|
||
currentUser = data.user;
|
||
|
||
if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
|
||
|
||
document.getElementById('sidebar-username').textContent = currentUser.username;
|
||
|
||
if (data.single_user_mode) {
|
||
document.getElementById('sidebar-logout').style.display = 'none';
|
||
startApp();
|
||
return;
|
||
}
|
||
|
||
if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
|
||
if (currentUser.first_login) { showPrivacyNotice(); return; }
|
||
|
||
startApp();
|
||
}
|
||
|
||
// ── Password change overlay ────────────────────────────────────────────────
|
||
|
||
function showChangePasswordOverlay() {
|
||
document.getElementById('change-password-overlay').classList.remove('hidden');
|
||
|
||
document.getElementById('change-password-form').onsubmit = async (e) => {
|
||
e.preventDefault();
|
||
const np = document.getElementById('cp-new').value;
|
||
const cnf = document.getElementById('cp-confirm').value;
|
||
const err = document.getElementById('cp-error');
|
||
|
||
if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
|
||
if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
|
||
err.textContent = '';
|
||
|
||
try {
|
||
const res = await fetch('/api/auth/change-password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ new_password: np }),
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body.error);
|
||
document.getElementById('change-password-overlay').classList.add('hidden');
|
||
currentUser.must_change_password = false;
|
||
currentUser.first_login ? showPrivacyNotice() : startApp();
|
||
} catch (ex) {
|
||
document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
|
||
}
|
||
};
|
||
}
|
||
|
||
// ── Privacy notice overlay ─────────────────────────────────────────────────
|
||
|
||
function showPrivacyNotice() {
|
||
document.getElementById('privacy-overlay').classList.remove('hidden');
|
||
document.getElementById('privacy-ack-btn').onclick = async () => {
|
||
await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
|
||
document.getElementById('privacy-overlay').classList.add('hidden');
|
||
currentUser.first_login = false;
|
||
startApp();
|
||
};
|
||
}
|
||
|
||
// ── Main app ───────────────────────────────────────────────────────────────
|
||
|
||
function startApp() {
|
||
document.getElementById('app').style.visibility = 'visible';
|
||
setupLogout();
|
||
setupNavLinks();
|
||
handleHash();
|
||
}
|
||
|
||
function setupLogout() {
|
||
document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
await fetch('/api/auth/logout', { method: 'POST' });
|
||
location.href = '/login.html';
|
||
});
|
||
}
|
||
|
||
function setupNavLinks() {
|
||
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
|
||
link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
|
||
});
|
||
window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
|
||
}
|
||
|
||
function handleHash() {
|
||
navigate(location.hash.replace('#', '') || 'tracker');
|
||
}
|
||
|
||
function navigate(page) {
|
||
if (!PAGES[page]) page = 'tracker';
|
||
|
||
document.querySelectorAll('.nav-link[data-page]').forEach(a => {
|
||
a.classList.toggle('active', a.dataset.page === page);
|
||
});
|
||
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
|
||
document.getElementById(PAGES[page].container).classList.add('active');
|
||
|
||
if (activePage !== page) {
|
||
activePage = page;
|
||
PAGES[page].init(document.getElementById(PAGES[page].container));
|
||
}
|
||
}
|
||
|
||
// ── Toast system ───────────────────────────────────────────────────────────
|
||
|
||
const TOAST_ICONS = {
|
||
success: '✓',
|
||
error: '✕',
|
||
warning: '⚠',
|
||
info: 'ℹ',
|
||
};
|
||
|
||
let toastContainer = null;
|
||
|
||
function getToastContainer() {
|
||
if (!toastContainer) {
|
||
toastContainer = document.createElement('div');
|
||
toastContainer.id = 'toast-container';
|
||
document.body.appendChild(toastContainer);
|
||
}
|
||
return toastContainer;
|
||
}
|
||
|
||
function showToast(msg, type = 'info', duration = 3500) {
|
||
const container = getToastContainer();
|
||
const toast = document.createElement('div');
|
||
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
|
||
|
||
toast.className = `toast toast-${type}`;
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${icon}</span>
|
||
<span class="toast-msg">${msg}</span>
|
||
<button class="toast-close" aria-label="Dismiss">×</button>
|
||
<div class="toast-bar"></div>
|
||
`;
|
||
|
||
container.appendChild(toast);
|
||
// Trigger animation
|
||
requestAnimationFrame(() => toast.classList.add('toast-show'));
|
||
|
||
const dismiss = () => {
|
||
toast.classList.remove('toast-show');
|
||
toast.classList.add('toast-hide');
|
||
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
||
};
|
||
|
||
const timer = setTimeout(dismiss, duration);
|
||
toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
|
||
}
|
||
|
||
// Hide app shell until auth check completes (prevents flash)
|
||
document.getElementById('app').style.visibility = 'hidden';
|
||
|
||
boot();
|