/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */ import { state } from './state.js'; // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- /** * Fetch JSON from an API endpoint with optional AbortSignal support. * Surfaces errors to the user via toast instead of silently failing. * * @param {string} path - API URL path. * @param {object} [opts] - Fetch options (may include signal). * @returns {Promise} Parsed JSON response. */ async function api(path, opts) { var res; try { // Inject auth header if authenticated const authHeaders = AuthManager.getAuthHeaders(); const mergedOpts = opts || {}; // Auto-set Content-Type for JSON bodies if (mergedOpts.body && typeof mergedOpts.body === "string" && !mergedOpts.headers?.["Content-Type"]) { mergedOpts.headers = { ...mergedOpts.headers, "Content-Type": "application/json" }; } if (authHeaders) { mergedOpts.headers = { ...mergedOpts.headers, ...authHeaders }; } mergedOpts.credentials = "include"; res = await fetch(path, mergedOpts); } catch (err) { if (err.name === "AbortError") throw err; // let callers handle abort showToast("Erreur réseau — vérifiez votre connexion", "error"); throw err; } if (res.status === 401 && AuthManager._authEnabled) { // Token expired — try refresh try { await AuthManager.refreshAccessToken(); // Retry the request with new token const retryHeaders = AuthManager.getAuthHeaders(); const retryOpts = opts || {}; retryOpts.headers = { ...retryOpts.headers, ...retryHeaders }; retryOpts.credentials = "include"; res = await fetch(path, retryOpts); } catch (refreshErr) { AuthManager.clearSession(); AuthManager.showLoginScreen(); throw new Error("Session expirée"); } } if (!res.ok) { var detail = ""; try { var body = await res.json(); detail = body.detail || ""; } catch (_) { /* no json body */ } showToast(detail || "Erreur API : " + res.status, "error"); throw new Error(detail || "API error: " + res.status); } return res.json(); } // --------------------------------------------------------------------------- // AuthManager — Authentication state & token management // --------------------------------------------------------------------------- const AuthManager = { ACCESS_TOKEN_KEY: "obsigate_access_token", TOKEN_EXPIRY_KEY: "obsigate_token_expiry", USER_KEY: "obsigate_user", _authEnabled: false, // ── Token storage (sessionStorage) ───────────────────────────── saveToken(tokenData) { const expiresAt = Date.now() + tokenData.expires_in * 1000; sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token); sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString()); if (tokenData.user) { sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); } }, getToken() { return sessionStorage.getItem(this.ACCESS_TOKEN_KEY); }, getUser() { const raw = sessionStorage.getItem(this.USER_KEY); return raw ? JSON.parse(raw) : null; }, isTokenExpired() { const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); if (!expiry) return true; // Renew 60s before expiration return Date.now() > parseInt(expiry) - 60000; }, clearSession() { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY); sessionStorage.removeItem(this.USER_KEY); }, getAuthHeaders() { const token = this.getToken(); if (!token || !this._authEnabled) return null; return { Authorization: "Bearer " + token }; }, // ── API calls ────────────────────────────────────────────────── async login(username, password, rememberMe) { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, password, remember_me: rememberMe || false }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || "Erreur de connexion"); } const data = await response.json(); this.saveToken(data); return data.user; }, async logout() { try { const token = this.getToken(); await fetch("/api/auth/logout", { method: "POST", headers: token ? { Authorization: "Bearer " + token } : {}, credentials: "include", }); } catch (e) { /* continue even if API fails */ } this.clearSession(); this.showLoginScreen(); }, async refreshAccessToken() { const response = await fetch("/api/auth/refresh", { method: "POST", credentials: "include", }); if (!response.ok) { this.clearSession(); throw new Error("Session expirée"); } const data = await response.json(); const expiry = Date.now() + data.expires_in * 1000; sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token); sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString()); return data.access_token; }, // ── UI controls ──────────────────────────────────────────────── showLoginScreen() { const app = document.getElementById("app"); const login = document.getElementById("login-screen"); if (app) app.classList.add("hidden"); if (login) { login.classList.remove("hidden"); const usernameInput = document.getElementById("login-username"); if (usernameInput) usernameInput.focus(); } }, showApp() { const login = document.getElementById("login-screen"); const app = document.getElementById("app"); if (login) login.classList.add("hidden"); if (app) app.classList.remove("hidden"); this.renderUserMenu(); }, renderUserMenu() { const user = this.getUser(); const userMenu = document.getElementById("user-menu"); if (!userMenu) return; if (!user || !this._authEnabled) { userMenu.innerHTML = ""; return; } userMenu.innerHTML = '' + (user.display_name || user.username) + "" + ''; safeCreateIcons(); const logoutBtn = document.getElementById("logout-btn"); if (logoutBtn) logoutBtn.addEventListener("click", () => AuthManager.logout()); const adminRow = document.getElementById("admin-menu-row"); if (adminRow) { if (user.role === "admin") { adminRow.classList.remove("hidden"); // Important: use an inline function to ensure we don't bind multiple identical listeners on rerenders, or clean up before adminRow.onclick = () => { closeHeaderMenu(); AdminPanel.show(); }; } else { adminRow.classList.add("hidden"); } } }, // ── Initialization ────────────────────────────────────────────── async checkAuthStatus() { try { const res = await fetch("/api/auth/status"); const data = await res.json(); this._authEnabled = data.auth_enabled; return data; } catch (e) { this._authEnabled = false; return { auth_enabled: false }; } }, async initAuth() { const status = await this.checkAuthStatus(); if (!status.auth_enabled) { // Auth disabled — show app immediately this.showApp(); return true; } // Auth enabled — check for existing session if (this.getToken() && !this.isTokenExpired()) { this.showApp(); return true; } // Try silent refresh try { await this.refreshAccessToken(); // Fetch user info const token = this.getToken(); const res = await fetch("/api/auth/me", { headers: { Authorization: "Bearer " + token }, credentials: "include", }); if (res.ok) { const user = await res.json(); sessionStorage.setItem(this.USER_KEY, JSON.stringify(user)); this.showApp(); return true; } } catch (e) { /* silent refresh failed */ } // No valid session — show login this.showLoginScreen(); return false; }, }; // --------------------------------------------------------------------------- // Login form handler // --------------------------------------------------------------------------- function initLoginForm() { const form = document.getElementById("login-form"); if (!form) return; form.addEventListener("submit", async (e) => { e.preventDefault(); const username = document.getElementById("login-username").value; const password = document.getElementById("login-password").value; const rememberMe = document.getElementById("remember-me").checked; const errorEl = document.getElementById("login-error"); const btn = document.getElementById("login-btn"); btn.disabled = true; btn.querySelector(".btn-spinner").classList.remove("hidden"); btn.querySelector(".btn-text").textContent = "Connexion..."; errorEl.classList.add("hidden"); try { await AuthManager.login(username, password, rememberMe); AuthManager.showApp(); // Load app data after successful login try { await Promise.all([loadVaults(), loadTags()]); // Start SSE sync now that auth cookie is set IndexUpdateManager.connect(); // Show dashboard showWelcome(); } catch (err) { console.error("Failed to load data after login:", err); } safeCreateIcons(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove("hidden"); document.getElementById("login-password").value = ""; document.getElementById("login-password").focus(); } finally { btn.disabled = false; btn.querySelector(".btn-spinner").classList.add("hidden"); btn.querySelector(".btn-text").textContent = "Se connecter"; } }); // Toggle password visibility const toggleBtn = document.getElementById("toggle-password"); if (toggleBtn) { toggleBtn.addEventListener("click", () => { const input = document.getElementById("login-password"); input.type = input.type === "password" ? "text" : "password"; }); } } // --------------------------------------------------------------------------- // Admin Panel — User management (admin only) // --------------------------------------------------------------------------- const AdminPanel = { _modal: null, _allVaults: [], show() { this._createModal(); this._modal.classList.add("active"); this._loadUsers(); }, hide() { if (this._modal) this._modal.classList.remove("active"); }, _createModal() { if (this._modal) return; this._modal = document.createElement("div"); this._modal.className = "editor-modal"; this._modal.id = "admin-modal"; this._modal.innerHTML = `
⚙️ Administration — Utilisateurs
`; document.body.appendChild(this._modal); safeCreateIcons(); document.getElementById("admin-close").addEventListener("click", () => this.hide()); document.getElementById("admin-add-user").addEventListener("click", () => this._showUserForm(null)); }, async _loadUsers() { try { const users = await api("/api/auth/admin/users"); // Also load available vaults try { const vaultsData = await api("/api/vaults"); this._allVaults = vaultsData.map((v) => v.name); } catch (e) { this._allVaults = []; } this._renderUsers(users); } catch (err) { document.getElementById("admin-users-list").innerHTML = '

Erreur : ' + err.message + "

"; } }, _renderUsers(users) { const container = document.getElementById("admin-users-list"); if (!users.length) { container.innerHTML = '

Aucun utilisateur.

'; return; } let html = '' + "" + ""; users.forEach((u) => { const vaults = u.vaults.includes("*") ? "Toutes" : u.vaults.join(", ") || "Aucune"; const status = u.active ? "✅" : "🔴"; const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString("fr-FR", { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "Jamais"; html += "" + "" + '" + '" + "" + "" + '"; }); html += "
UtilisateurRôleVaultsStatutDernière connexionActions
" + u.username + "" + (u.display_name && u.display_name !== u.username ? "
" + u.display_name + "" : "") + "
' + u.role + "' + vaults + "" + status + "" + lastLogin + "' + '' + '' + "
"; container.innerHTML = html; // Bind action buttons container.querySelectorAll('[data-action="edit"]').forEach((btn) => { btn.addEventListener("click", () => { const user = users.find((u) => u.username === btn.dataset.username); if (user) this._showUserForm(user); }); }); container.querySelectorAll('[data-action="delete"]').forEach((btn) => { btn.addEventListener("click", () => this._deleteUser(btn.dataset.username)); }); }, _showUserForm(user) { const isEdit = !!user; const title = isEdit ? "Modifier : " + user.username : "Nouvel utilisateur"; const vaultCheckboxes = this._allVaults .map((v) => { const checked = user && (user.vaults.includes(v) || user.vaults.includes("*")) ? "checked" : ""; return '"; }) .join(""); const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : ""; // Create form modal overlay const overlay = document.createElement("div"); overlay.className = "admin-form-overlay"; overlay.innerHTML = `

${title}

${!isEdit ? '
' : ""}
${vaultCheckboxes}
${isEdit ? '
" : ""}
`; this._modal.appendChild(overlay); document.getElementById("admin-form-cancel").addEventListener("click", () => overlay.remove()); document.getElementById("admin-user-form").addEventListener("submit", async (e) => { e.preventDefault(); const form = e.target; const state.allVaults = document.getElementById("admin-all-vaults").checked; const selectedVaults = state.allVaults ? ["*"] : Array.from(form.querySelectorAll('input[name="vault"]:checked')).map((cb) => cb.value); try { if (isEdit) { const updates = { display_name: form.display_name.value || null, role: form.role.value, vaults: selectedVaults, }; if (form.password.value) updates.password = form.password.value; const activeCheckbox = form.querySelector('input[name="active"]'); if (activeCheckbox) updates.active = activeCheckbox.checked; await api("/api/auth/admin/users/" + user.username, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }); } else { await api("/api/auth/admin/users", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: form.username.value, password: form.password.value, display_name: form.display_name.value || null, role: form.role.value, vaults: selectedVaults, }), }); } overlay.remove(); this._loadUsers(); showToast(isEdit ? "Utilisateur modifié" : "Utilisateur créé", "success"); } catch (err) { showToast(err.message, "error"); } }); }, async _deleteUser(username) { const currentUser = AuthManager.getUser(); if (currentUser && currentUser.username === username) { showToast("Impossible de supprimer son propre compte", "error"); return; } if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return; try { await api("/api/auth/admin/users/" + username, { method: "DELETE" }); this._loadUsers(); showToast("Utilisateur supprimé", "success"); } catch (err) { showToast(err.message, "error"); } }, }; export { api, AuthManager, initLoginForm, AdminPanel };