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.
548 lines
20 KiB
JavaScript
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 };
|