Files
ObsiGate/frontend/js/auth.js
T
bruno 4836d6f1d0
CI / lint (push) Successful in 10s
CI / security (push) Successful in 8s
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
refactor: split app.js (8875 lines) into 12 ES modules
frontend/js/ structure:
  state.js      (55 lines)  — Shared mutable state, constants
  utils.js      (510 lines) — EXT_ICONS, getFileIcon, escapeHtml, safeCreateIcons
  auth.js       (547 lines) — api(), AuthManager, initLoginForm, AdminPanel
  search.js     (1106 lines)— SearchHistory, QueryParser, Autocomplete, performSearch
  sidebar.js    (1091 lines)— Vault tree, sidebar filter, TagFilterService, loadTags
  viewer.js     (1554 lines)— openFile, Outline, ScrollSpy, Frontmatter, Editor
  ui.js         (2250 lines)— Theme, Toast, Sidebar, Dropdowns, Tabs, ContextMenu
  dashboard.js  (461 lines) — Dashboard widgets (Recent, Stats, Bookmarks)
  config.js     (999 lines) — Config panel, Hidden files, About, Sidebar tabs
  sync.js       (436 lines) — SSE/IndexUpdateManager, PWA registration
  graph.js      (401 lines) — GraphViewManager (force-directed canvas graph)
  legacy.js     (550 lines) — Remaining bridge functions (goHome, showWelcome, initSearch)
  app.js        (80 lines)  — Thin orchestrator: imports all modules, calls init()

index.html: switched from <script src="app.js"> to <script type="module" src="js/app.js">
Original app.js preserved for backward compatibility.
All 14 modules pass node --check syntax validation.
2026-05-28 14:04:50 -04:00

548 lines
20 KiB
JavaScript

/* ObsiGate — Authentication: API helper, AuthManager, login form, AdminPanel */
import * as 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<any>} 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 = '<span class="user-display-name">' + (user.display_name || user.username) + "</span>" + '<button class="btn-logout" id="logout-btn" title="Déconnexion"><i data-lucide="log-out" style="width:14px;height:14px"></i></button>';
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 = `
<div class="editor-container">
<div class="editor-header">
<div class="editor-title">⚙️ Administration — Utilisateurs</div>
<div class="editor-actions">
<button class="editor-btn" id="admin-close" title="Fermer">
<i data-lucide="x" style="width:16px;height:16px"></i>
</button>
</div>
</div>
<div class="editor-body" id="admin-body">
<div class="admin-toolbar">
<button class="btn-login" id="admin-add-user" style="font-size:0.85rem;padding:6px 16px;">+ Nouvel utilisateur</button>
</div>
<div id="admin-users-list" class="admin-users-list"></div>
</div>
</div>
`;
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 = '<p style="color:var(--danger);padding:16px;">Erreur : ' + err.message + "</p>";
}
},
_renderUsers(users) {
const container = document.getElementById("admin-users-list");
if (!users.length) {
container.innerHTML = '<p style="padding:16px;color:var(--text-muted);">Aucun utilisateur.</p>';
return;
}
let html = '<table class="admin-table"><thead><tr>' + "<th>Utilisateur</th><th>Rôle</th><th>Vaults</th><th>Statut</th><th>Dernière connexion</th><th>Actions</th>" + "</tr></thead><tbody>";
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 +=
"<tr>" +
"<td><strong>" +
u.username +
"</strong>" +
(u.display_name && u.display_name !== u.username ? "<br><small>" + u.display_name + "</small>" : "") +
"</td>" +
'<td><span class="admin-role-badge admin-role-' +
u.role +
'">' +
u.role +
"</span></td>" +
'<td><span class="admin-vaults-text">' +
vaults +
"</span></td>" +
"<td>" +
status +
"</td>" +
"<td><small>" +
lastLogin +
"</small></td>" +
'<td class="admin-actions">' +
'<button class="admin-action-btn" data-action="edit" data-username="' +
u.username +
'" title="Modifier">✏️</button>' +
'<button class="admin-action-btn danger" data-action="delete" data-username="' +
u.username +
'" title="Supprimer">🗑️</button>' +
"</td></tr>";
});
html += "</tbody></table>";
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 '<label class="checkbox-label"><input type="checkbox" name="vault" value="' + v + '" ' + checked + "><span>" + v + "</span></label>";
})
.join("");
const allVaultsChecked = user && user.vaults.includes("*") ? "checked" : "";
// Create form modal overlay
const overlay = document.createElement("div");
overlay.className = "admin-form-overlay";
overlay.innerHTML = `
<div class="admin-form-card">
<h3>${title}</h3>
<form id="admin-user-form">
${!isEdit ? '<div class="form-group"><label>Nom d\'utilisateur</label><input type="text" name="username" required pattern="[a-zA-Z0-9_-]{2,32}" placeholder="username"></div>' : ""}
<div class="form-group"><label>Nom affiché</label><input type="text" name="display_name" value="${isEdit ? user.display_name || "" : ""}"></div>
<div class="form-group"><label>${isEdit ? "Nouveau mot de passe (vide = inchangé)" : "Mot de passe"}</label><input type="password" name="password" ${!isEdit ? 'required minlength="8"' : ""} placeholder="${isEdit ? "Laisser vide pour ne pas changer" : "Min. 8 caractères"}"></div>
<div class="form-group"><label>Rôle</label><select name="role"><option value="user" ${isEdit && user.role === "user" ? "selected" : ""}>Utilisateur</option><option value="admin" ${isEdit && user.role === "admin" ? "selected" : ""}>Admin</option></select></div>
<div class="form-group">
<label>Vaults autorisées</label>
<div class="admin-vault-list">${vaultCheckboxes}</div>
<label class="checkbox-label" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;"><input type="checkbox" id="admin-all-vaults" ${allVaultsChecked}><span><strong>Accès total</strong> (toutes les vaults, y compris futures)</span></label>
</div>
${isEdit ? '<div class="form-group"><label>Compte actif</label><label class="checkbox-label"><input type="checkbox" name="active" ' + (user.active ? "checked" : "") + "><span>Actif</span></label></div>" : ""}
<div class="admin-form-actions">
<button type="button" class="config-btn-secondary" id="admin-form-cancel">Annuler</button>
<button type="submit" class="btn-login" style="font-size:0.85rem;padding:6px 20px;">Enregistrer</button>
</div>
</form>
</div>
`;
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("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 };