/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */ import { state } from './state.js'; import { safeCreateIcons, escapeHtml } from './utils.js'; import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js'; import { t, getLocale, setLocale } from './i18n.js'; import { showWelcome } from './viewer.js'; // ── Global logout handler for header dropdown onclick ──────────────────── // Defined at module top level so it's always available, even if // renderUserMenu() hasn't been called yet. // Uses a getter to lazy-resolve AuthManager (defined later in this module). window.handleLogout = () => { AuthManager.logout(); }; /** Two-letter initials for the sidebar account avatar (#112). */ function userInitials(name) { const parts = String(name || "").trim().split(/\s+/).filter(Boolean); if (!parts.length) return "?"; const first = parts[0][0] || ""; const last = parts.length > 1 ? parts[parts.length - 1][0] : ""; return (first + last).toUpperCase() || "?"; } // --------------------------------------------------------------------------- // 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(t("auth.network_error"), "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 = ""; var code = ""; var details = null; try { var body = await res.json(); detail = body.detail || ""; // #153 A1 : the service layer exposes a stable code + details so callers // can branch on the failure (e.g. confirm a lossy .xlsx write) instead of // matching on the message. code = body.code || ""; details = body.details || null; } catch (_) { /* no json body */ } showToast(detail || "Erreur API : " + res.status, "error"); var apiError = new Error(detail || "API error: " + res.status); apiError.status = res.status; apiError.code = code; apiError.details = details; throw apiError; } 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, // BUG-034: the access token is kept in memory only. The server also sets it // as an HttpOnly cookie, so a page reload re-authenticates via /api/auth/refresh // without ever exposing the token to JavaScript-readable storage. _accessToken: null, // ── Token storage (in-memory + HttpOnly cookie) ──────────────── saveToken(tokenData) { const expiresAt = Date.now() + tokenData.expires_in * 1000; this._accessToken = tokenData.access_token; // Clear any token persisted by an older build (XSS-readable). try { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); } catch (e) { /* ignore */ } sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString()); if (tokenData.user) { sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); } }, getToken() { return this._accessToken; }, hasSession() { return !!this._accessToken || !!sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); }, getUser() { const raw = sessionStorage.getItem(this.USER_KEY); return raw ? JSON.parse(raw) : null; }, /** Merge fields into the cached user object (#113 — profile avatar). */ updateCachedUser(fields) { const next = { ...(this.getUser() || {}), ...fields }; sessionStorage.setItem(this.USER_KEY, JSON.stringify(next)); return next; }, /** Whether authentication is enabled on this instance (#113). */ isAuthEnabled() { return !!this._authEnabled; }, isTokenExpired() { const expiry = sessionStorage.getItem(this.TOKEN_EXPIRY_KEY); if (!expiry) return true; // Renew 60s before expiration return Date.now() > parseInt(expiry) - 60000; }, clearSession() { this._accessToken = null; try { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); } catch (e) { /* ignore */ } 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(); // If MFA is required, return the MFA challenge instead of saving token if (data.mfa_required) { return data; } this.saveToken(data); return data.user; }, async verifyMfa(username, code, rememberMe) { const response = await fetch("/api/auth/mfa/totp/verify", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, code, remember_me: rememberMe || false }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || "Code invalide"); } const data = await response.json(); this.saveToken(data); return data.user; }, async verifyRecovery(username, recoveryCode) { const response = await fetch("/api/auth/mfa/recovery", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, recovery_code: recoveryCode }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || "Code invalide"); } const data = await response.json(); this.saveToken(data); return data.user; }, // ── WebAuthn (ROADMAP #64) ───────────────────────────────────────── async webauthnLoginOptions(username) { const resp = await fetch("/api/auth/mfa/webauthn/options", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username }), }); if (!resp.ok) throw new Error((await resp.json()).detail || "WebAuthn indisponible"); return await resp.json(); }, async verifyWebauthn(username, credential, rememberMe) { const response = await fetch("/api/auth/mfa/webauthn/verify", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, credential, remember_me: rememberMe || false }), }); if (!response.ok) { const err = await response.json(); throw new Error(err.detail || "Vérification WebAuthn échouée"); } const data = await response.json(); this.saveToken(data); return data.user; }, async webauthnRegisterOptions() { return await api("/api/auth/mfa/webauthn/register/options", { method: "POST" }); }, async webauthnRegister(credential, label) { return await api("/api/auth/mfa/webauthn/register", { method: "POST", body: JSON.stringify({ credential, label }), }); }, async webauthnCredentials() { return await api("/api/auth/mfa/webauthn/credentials"); }, async webauthnRemove(credentialId, password) { return await api("/api/auth/mfa/webauthn/credentials/remove", { method: "POST", body: JSON.stringify({ credential_id: credentialId, password }), }); }, // ── MFA Setup API calls ────────────────────────────────────────── async getMfaStatus() { const resp = await api("/api/auth/mfa/status"); return resp; }, async mfaSetup() { return await api("/api/auth/mfa/totp/setup", { method: "POST" }); }, async mfaEnable(code) { return await api("/api/auth/mfa/totp/enable", { method: "POST", body: JSON.stringify({ code }), }); }, async mfaDisable(password, code) { return await api("/api/auth/mfa/totp/disable", { method: "POST", body: JSON.stringify({ password, code }), }); }, async changePassword(currentPassword, newPassword) { return await api("/api/auth/change-password", { method: "POST", body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), }); }, 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; this._accessToken = data.access_token; try { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); } catch (e) { /* ignore */ } 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.renderUserSection(); }, /** * Render the account block pinned at the bottom of the sidebar (#112) — * the user identity/logout no longer live in the header. */ renderUserSection() { const user = this.getUser(); const section = document.getElementById("sidebar-user"); if (!user || !this._authEnabled) { if (section) section.hidden = true; return; } if (!section) return; const name = user.display_name || user.username || ""; const nameEl = document.getElementById("sidebar-user-name"); if (nameEl) nameEl.textContent = name; const roleEl = document.getElementById("sidebar-user-role"); if (roleEl) roleEl.textContent = user.role === "admin" ? t("sidebar.user_role_admin") : t("sidebar.user_role_user"); const avatarEl = document.getElementById("sidebar-user-avatar"); if (avatarEl) { // #113: custom avatar image when set, initials as the fallback. if (user.avatar) { avatarEl.textContent = ""; const img = document.createElement("img"); img.src = user.avatar; img.alt = ""; img.className = "sidebar-user-avatar-img"; avatarEl.appendChild(img); } else { avatarEl.textContent = userInitials(name); } } section.hidden = false; safeCreateIcons(); const logoutBtn = document.getElementById("sidebar-user-logout"); if (logoutBtn && !logoutBtn._obsigateBound) { logoutBtn._obsigateBound = true; logoutBtn.addEventListener("click", () => this.logout()); } const profileBtn = document.getElementById("sidebar-user-profile"); if (profileBtn && !profileBtn._obsigateBound) { profileBtn._obsigateBound = true; profileBtn.addEventListener("click", () => { closeMobileSidebar(); const trigger = document.getElementById("profile-open-btn"); if (trigger) trigger.click(); }); } 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(); window.location.href = "/admin.html"; }; } else { adminRow.classList.add("hidden"); } } }, // Backwards-compatible alias (older callers/tests may use this name). renderUserMenu() { this.renderUserSection(); }, // ── 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.hasSession() && !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; }, }; // --------------------------------------------------------------------------- // Post-login setup (shared between normal login and MFA login) // --------------------------------------------------------------------------- async function _onLoginSuccess() { AuthManager.showApp(); // Re-sync language from server now that we're authenticated try { const resp = await fetch('/api/auth/me', { credentials: 'include' }); if (resp.ok) { const user = await resp.json(); if (user.language && user.language !== getLocale()) { await setLocale(user.language); } } } catch (e) { /* non-bloquant */ } // Load app data after successful login try { const { loadVaults, loadTags } = await import('./sidebar.js'); await Promise.all([loadVaults(), loadTags()]); const { IndexUpdateManager } = await import('./sync.js'); IndexUpdateManager.connect(); const { syncFileIndexFromServer } = await import('./offline.js'); syncFileIndexFromServer(); showWelcome(); } catch (err) { console.error("Failed to load data after login:", err); } } // --------------------------------------------------------------------------- // MFA Challenge UI (TOTP code input during login) // --------------------------------------------------------------------------- // base64url <-> ArrayBuffer helpers for WebAuthn (ROADMAP #64) function _b64urlToBuf(s) { const pad = "=".repeat((4 - (s.length % 4)) % 4); const b = (s + pad).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(b); return Uint8Array.from(raw, (c) => c.charCodeAt(0)); } function _bufToB64url(buf) { const bytes = new Uint8Array(buf); let s = ""; for (const c of bytes) s += String.fromCharCode(c); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async function runWebauthnCeremony(optionsJson) { const pkOptions = { challenge: _b64urlToBuf(optionsJson.challenge), timeout: optionsJson.timeout || 60000, rpId: optionsJson.rpId, userVerification: optionsJson.userVerification || "preferred", allowCredentials: (optionsJson.allowCredentials || []).map((c) => ({ type: c.type || "public-key", id: _b64urlToBuf(c.id), transports: c.transports, })), }; const cred = await navigator.credentials.get({ publicKey: pkOptions }); if (!cred) throw new Error(t("mfa.webauthn_cancelled")); return { id: cred.id, rawId: _bufToB64url(cred.rawId), type: cred.type, response: { clientDataJSON: _bufToB64url(cred.response.clientDataJSON), authenticatorData: _bufToB64url(cred.response.authenticatorData), signature: _bufToB64url(cred.response.signature), userHandle: cred.response.userHandle ? _bufToB64url(cred.response.userHandle) : "", }, }; } async function runWebauthnRegistration() { const resp = await AuthManager.webauthnRegisterOptions(); const o = resp.options; const pkOptions = { rp: { id: o.rpId || o.rp.id, name: o.rp.name }, challenge: _b64urlToBuf(o.challenge), user: { id: _b64urlToBuf(o.user.id), name: o.user.name, displayName: o.user.displayName, }, pubKeyCredParams: o.pubKeyCredParams, timeout: o.timeout || 60000, authenticatorSelection: o.authenticatorSelection || undefined, attestation: "none", }; const cred = await navigator.credentials.create({ publicKey: pkOptions }); if (!cred) throw new Error(t("mfa.webauthn_cancelled")); const credential = { id: cred.id, rawId: _bufToB64url(cred.rawId), type: cred.type, response: { clientDataJSON: _bufToB64url(cred.response.clientDataJSON), attestationObject: _bufToB64url(cred.response.attestationObject), }, }; if (cred.response.getTransports) { credential.response.transports = Array.from(cred.response.getTransports()); } return credential; } function _startWebauthnLogin(mfaSection, username, rememberMe) { const btn = mfaSection.querySelector("#mfa-webauthn-btn"); const errorEl = mfaSection.querySelector("#mfa-error"); if (btn) btn.disabled = true; if (errorEl) errorEl.classList.add("hidden"); (async () => { try { const resp = await AuthManager.webauthnLoginOptions(username); if (resp.mfa_method !== "webauthn" || !resp.options) { throw new Error(t("mfa.webauthn_no_key")); } const credential = await runWebauthnCeremony(resp.options); await AuthManager.verifyWebauthn(username, credential, rememberMe); mfaSection.classList.add("hidden"); const loginForm = document.getElementById("login-form"); if (loginForm) loginForm.classList.remove("hidden"); await _onLoginSuccess(); } catch (err) { if (errorEl) { errorEl.textContent = err.message || String(err); errorEl.classList.remove("hidden"); } } finally { if (btn) btn.disabled = false; } })(); } function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) { // BUG-069: the challenge used to mount into `.login-box`, which does not // exist in index.html (the login markup is `#login-screen > .login-card > // #login-form`) — querySelector returned null and the function silently // returned, leaving the user stuck on the login page with no error after // entering correct credentials. Mount into the real card, and never fail // silently: surface the problem in the login error box instead. const loginBox = document.querySelector(".login-card") || document.getElementById("login-screen"); if (!loginBox) { const fallback = loginErrorEl || document.getElementById("login-error"); if (fallback) { fallback.textContent = t("mfa.challenge_unavailable"); fallback.classList.remove("hidden"); } return; } // Hide the normal login form const loginForm = document.getElementById("login-form"); if (loginForm) loginForm.classList.add("hidden"); // Create MFA challenge UI let mfaSection = document.getElementById("mfa-challenge"); if (!mfaSection) { mfaSection = document.createElement("div"); mfaSection.id = "mfa-challenge"; mfaSection.className = "mfa-challenge"; loginBox.appendChild(mfaSection); } // WebAuthn second factor: key prompt instead of TOTP code input if (mfaMethod === "webauthn") { mfaSection.innerHTML = `
🔑

${t('mfa.title')}

${t('mfa.webauthn_prompt')}

`; mfaSection.classList.remove("hidden"); document.getElementById("mfa-webauthn-btn").addEventListener("click", () => { _startWebauthnLogin(mfaSection, username, rememberMe); }); document.getElementById("mfa-use-recovery").addEventListener("click", () => { showRecoveryChallenge(username, rememberMe, loginForm, mfaSection); }); document.getElementById("mfa-back-login").addEventListener("click", () => { mfaSection.classList.add("hidden"); if (loginForm) loginForm.classList.remove("hidden"); }); // Auto-start the ceremony — the browser shows its own dialog _startWebauthnLogin(mfaSection, username, rememberMe); return; } mfaSection.innerHTML = `
🔐

${t('mfa.title')}

${t('mfa.subtitle')}

`; mfaSection.classList.remove("hidden"); const codeInput = document.getElementById("mfa-code"); codeInput.focus(); // Auto-submit when 6 digits entered codeInput.addEventListener("input", () => { codeInput.value = codeInput.value.replace(/[^0-9]/g, ""); if (codeInput.value.length === 6) { document.getElementById("mfa-verify-btn").click(); } }); // Handle MFA form submit document.getElementById("mfa-form").addEventListener("submit", async (e) => { e.preventDefault(); const code = codeInput.value.trim(); if (code.length !== 6) return; const btn = document.getElementById("mfa-verify-btn"); const errorEl = document.getElementById("mfa-error"); btn.disabled = true; btn.querySelector(".btn-spinner").classList.remove("hidden"); btn.querySelector(".btn-text").textContent = t('mfa.verifying'); errorEl.classList.add("hidden"); try { await AuthManager.verifyMfa(username, code, rememberMe); mfaSection.classList.add("hidden"); if (loginForm) loginForm.classList.remove("hidden"); await _onLoginSuccess(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove("hidden"); codeInput.value = ""; codeInput.focus(); } finally { btn.disabled = false; btn.querySelector(".btn-spinner").classList.add("hidden"); btn.querySelector(".btn-text").textContent = t('mfa.verify'); } }); // Switch to recovery code input document.getElementById("mfa-use-recovery").addEventListener("click", () => { showRecoveryChallenge(username, rememberMe, loginForm, mfaSection); }); // Back to login document.getElementById("mfa-back-login").addEventListener("click", () => { mfaSection.classList.add("hidden"); if (loginForm) loginForm.classList.remove("hidden"); }); } function showRecoveryChallenge(username, rememberMe, loginForm, mfaSection) { mfaSection.innerHTML = `
🔑

${t('mfa.recovery_title')}

${t('mfa.recovery_subtitle')}

`; const codeInput = document.getElementById("recovery-code"); codeInput.focus(); // Auto-format: insert dash after 4 chars codeInput.addEventListener("input", () => { let v = codeInput.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); if (v.length > 4) v = v.slice(0, 4) + "-" + v.slice(4, 8); codeInput.value = v; }); document.getElementById("recovery-form").addEventListener("submit", async (e) => { e.preventDefault(); const code = codeInput.value.trim(); const btn = document.getElementById("recovery-verify-btn"); const errorEl = document.getElementById("recovery-error"); btn.disabled = true; btn.querySelector(".btn-spinner").classList.remove("hidden"); btn.querySelector(".btn-text").textContent = t('mfa.verifying'); errorEl.classList.add("hidden"); try { await AuthManager.verifyRecovery(username, code); mfaSection.classList.add("hidden"); if (loginForm) loginForm.classList.remove("hidden"); await _onLoginSuccess(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove("hidden"); codeInput.value = ""; codeInput.focus(); } finally { btn.disabled = false; btn.querySelector(".btn-spinner").classList.add("hidden"); btn.querySelector(".btn-text").textContent = t('mfa.verify'); } }); document.getElementById("recovery-use-totp").addEventListener("click", () => { showMfaChallenge(username, rememberMe, null, null); }); document.getElementById("recovery-back-login").addEventListener("click", () => { mfaSection.classList.add("hidden"); if (loginForm) loginForm.classList.remove("hidden"); }); } // --------------------------------------------------------------------------- // 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 { const result = await AuthManager.login(username, password, rememberMe); // Check if MFA is required if (result && result.mfa_required) { showMfaChallenge(result.username, rememberMe, btn, errorEl, result.mfa_method); return; } // Normal login success await _onLoginSuccess(); 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 = `
⚙️ ${t('admin.title')}
`; 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("*") ? t('common.all') : u.vaults.join(", ") || t('common.none'); const status = u.active ? "✅" : "🔴"; const lastLogin = u.last_login ? new Date(u.last_login).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US', { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : t('common.never'); html += "" + "" + '" + '" + "" + "" + '"; }); html += "
' + t('admin.col_user') + '' + t('admin.col_role') + '' + t('admin.col_vaults') + '' + t('admin.col_status') + '' + t('admin.col_last_login') + '' + t('admin.col_actions') + '
" + 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 allVaults = document.getElementById("admin-all-vaults").checked; const selectedVaults = 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(t("auth.cannot_delete_self"), "error"); return; } if (!confirm("Supprimer l'utilisateur \"" + username + '" ?')) return; try { await api("/api/auth/admin/users/" + username, { method: "DELETE" }); this._loadUsers(); showToast(t("auth.user_deleted"), "success"); } catch (err) { showToast(err.message, "error"); } }, }; // --------------------------------------------------------------------------- // MFA Settings — Setup/Disable UI in the settings panel // --------------------------------------------------------------------------- async function initMfaSettings() { const area = document.getElementById("mfa-setup-area"); const badge = document.getElementById("mfa-status-badge"); if (!area || !badge) return; // Check current MFA status let mfaEnabled = false; try { const status = await AuthManager.getMfaStatus(); mfaEnabled = status.mfa_enabled; } catch (e) { // Not logged in or error return; } if (mfaEnabled) { badge.textContent = t("mfa.enabled"); badge.className = "mfa-badge mfa-badge-on"; area.innerHTML = `

${t("mfa.enabled_desc")}

`; document.getElementById("mfa-disable-btn").addEventListener("click", () => { _showDisableMfaForm(); }); } else { badge.textContent = t("mfa.disabled"); badge.className = "mfa-badge mfa-badge-off"; area.innerHTML = `

${t("mfa.setup_desc")}

`; document.getElementById("mfa-enable-btn").addEventListener("click", () => { _startMfaSetup(); }); } // Password change (BUG-068: the "Sécurité du compte" section had no way to // change the password although POST /api/auth/change-password exists). _renderPasswordSection(area); // WebAuthn security keys section (ROADMAP #64) _renderWebauthnSection(area); } function _renderPasswordSection(container) { if (!container || document.getElementById("password-settings")) return; const section = document.createElement("div"); section.id = "password-settings"; section.className = "password-settings"; section.innerHTML = `

${t("mfa.password_change_title")}

${t("mfa.password_change_desc")}

`; container.appendChild(section); section.querySelector("#pwd-change-btn").addEventListener("click", async () => { const errEl = section.querySelector("#pwd-change-error"); const current = section.querySelector("#pwd-current").value; const next = section.querySelector("#pwd-new").value; const confirm = section.querySelector("#pwd-confirm").value; const btn = section.querySelector("#pwd-change-btn"); errEl.classList.add("hidden"); if (!current || !next || !confirm) { errEl.textContent = t("mfa.fill_all_fields"); errEl.classList.remove("hidden"); return; } if (next !== confirm) { errEl.textContent = t("mfa.password_mismatch"); errEl.classList.remove("hidden"); return; } btn.disabled = true; try { await AuthManager.changePassword(current, next); showToast(t("mfa.password_changed"), "success"); section.querySelector("#pwd-current").value = ""; section.querySelector("#pwd-new").value = ""; section.querySelector("#pwd-confirm").value = ""; } catch (err) { errEl.textContent = err.message || String(err); errEl.classList.remove("hidden"); } finally { btn.disabled = false; } }); } async function _renderWebauthnSection(container) { if (!container || !window.PublicKeyCredential) return; let keys = []; try { const resp = await AuthManager.webauthnCredentials(); keys = resp.credentials || []; } catch (e) { return; // auth disabled or endpoint unreachable — hide section } let section = document.getElementById("webauthn-settings"); if (!section) { section = document.createElement("div"); section.id = "webauthn-settings"; section.className = "webauthn-settings"; container.appendChild(section); } const listHtml = keys.length ? `` : `

${t("mfa.webauthn_none")}

`; section.innerHTML = `

${t("mfa.webauthn_title")}

${t("mfa.webauthn_desc")}

${listHtml}
`; const errEl = section.querySelector("#webauthn-error"); document.getElementById("webauthn-add-btn").addEventListener("click", async () => { errEl.classList.add("hidden"); try { const credential = await runWebauthnRegistration(); const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé"); const result = await AuthManager.webauthnRegister(credential, label || "Security key"); if (result.recovery_codes && result.recovery_codes.length) { // BUG-068: first-time WebAuthn enable issues recovery codes. There is // no #mfa-setup-flow-area in the "already enabled" view, so render // them into the WebAuthn flow area instead of losing them. _showRecoveryCodes(result.recovery_codes, "webauthn-flow-area"); } else { showToast(t("mfa.webauthn_added"), "success"); } initMfaSettings(); } catch (err) { errEl.textContent = err.message || String(err); errEl.classList.remove("hidden"); } }); section.querySelectorAll(".webauthn-key-remove").forEach((btn) => { btn.addEventListener("click", async () => { const password = prompt(t("mfa.webauthn_remove_confirm")); if (password === null) return; try { await AuthManager.webauthnRemove(btn.dataset.id, password); showToast(t("mfa.webauthn_removed"), "success"); initMfaSettings(); } catch (err) { errEl.textContent = err.message || String(err); errEl.classList.remove("hidden"); } }); }); } async function _startMfaSetup() { const flowArea = document.getElementById("mfa-setup-flow-area"); if (!flowArea) return; try { const data = await AuthManager.mfaSetup(); // BUG-068: the QR code comes from the backend as a local SVG data: URI // (see POST /api/auth/mfa/totp/setup → qr_data_url). The previous // third-party QR image was blocked by the CSP // (img-src 'self' data: blob:) so it never displayed — and it leaked the // otpauth URI (TOTP secret) to a third party. Fall back to the manual // secret when the backend has no QR generator available. const qrImg = data.qr_data_url ? `QR Code` : ""; const fallbackStyle = data.qr_data_url ? "display:none" : ""; flowArea.innerHTML = `

${t("mfa.scan_qr")}

${qrImg}

${t("mfa.qr_unavailable")}

${t("mfa.manual_entry")} ${escapeHtml(data.secret)}
`; const codeInput = document.getElementById("mfa-enable-code"); codeInput.addEventListener("input", () => { codeInput.value = codeInput.value.replace(/[^0-9]/g, ""); }); // QR fallback (#87, ex-onerror inline) : si l'image ne charge pas, // afficher la saisie manuelle du secret. const qrImgEl = document.getElementById("mfa-qr-img"); if (qrImgEl) { qrImgEl.addEventListener("error", () => { qrImgEl.style.display = "none"; const fallback = document.getElementById("mfa-qr-fallback"); if (fallback) fallback.style.display = "block"; }); } document.getElementById("mfa-confirm-btn").addEventListener("click", async () => { const code = codeInput.value.trim(); if (code.length !== 6) return; const btn = document.getElementById("mfa-confirm-btn"); const errorEl = document.getElementById("mfa-enable-error"); btn.disabled = true; btn.textContent = t("mfa.verifying"); errorEl.classList.add("hidden"); try { const result = await AuthManager.mfaEnable(code); // Show recovery codes _showRecoveryCodes(result.recovery_codes); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove("hidden"); codeInput.value = ""; codeInput.focus(); } finally { btn.disabled = false; btn.textContent = t("mfa.confirm_enable"); } }); } catch (err) { flowArea.innerHTML = `

${err.message}

`; } } function _showRecoveryCodes(codes, targetId) { // BUG-068: the recovery codes must be visible wherever the enable flow ran. // The TOTP flow owns #mfa-setup-flow-area, but the WebAuthn first-enable // path (#webauthn-flow-area) has none — previously those codes were lost. const flowArea = document.getElementById(targetId || "mfa-setup-flow-area") || document.getElementById("webauthn-flow-area") || document.getElementById("mfa-setup-area"); if (!flowArea) return; const codesHtml = codes.map(c => `${escapeHtml(c)}`).join("\n"); flowArea.innerHTML = `

🔑 ${t("mfa.recovery_codes_title")}

${t("mfa.recovery_codes_warning")}

${codesHtml}
`; document.getElementById("mfa-copy-codes").addEventListener("click", () => { navigator.clipboard.writeText(codes.join("\n")).then(() => { showToast(t("mfa.codes_copied"), "success"); }); }); document.getElementById("mfa-download-codes").addEventListener("click", () => { const blob = new Blob([codes.join("\n")], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "obsigate-recovery-codes.txt"; a.click(); URL.revokeObjectURL(url); }); document.getElementById("mfa-codes-done").addEventListener("click", () => { // Refresh MFA settings display initMfaSettings(); }); } function _showDisableMfaForm() { const formArea = document.getElementById("mfa-disable-form-area"); if (!formArea) return; formArea.innerHTML = `

${t("mfa.disable_confirm_title")}

${t("mfa.disable_confirm_desc")}

`; document.getElementById("mfa-disable-cancel").addEventListener("click", () => { formArea.innerHTML = ""; }); document.getElementById("mfa-disable-confirm").addEventListener("click", async () => { const password = document.getElementById("mfa-disable-password").value; const code = document.getElementById("mfa-disable-code").value.trim(); const errorEl = document.getElementById("mfa-disable-error"); const btn = document.getElementById("mfa-disable-confirm"); if (!password || code.length !== 6) { errorEl.textContent = t("mfa.fill_all_fields"); errorEl.classList.remove("hidden"); return; } btn.disabled = true; btn.textContent = t("mfa.verifying"); errorEl.classList.add("hidden"); try { await AuthManager.mfaDisable(password, code); showToast(t("mfa.disabled_success"), "success"); initMfaSettings(); } catch (err) { errorEl.textContent = err.message; errorEl.classList.remove("hidden"); } finally { btn.disabled = false; btn.textContent = t("mfa.disable_confirm_btn"); } }); } export { api, AuthManager, initLoginForm, AdminPanel, initMfaSettings };