[Coffre Secrets] Phase 1 complète : chiffrement, API et UI (1a + 1b + 1c) #25

Merged
bruno merged 12 commits from feat/coffre-secrets-phase1 into main 2026-10-27 22:29:10 -04:00
Showing only changes of commit 2dc8be3127 - Show all commits
+177
View File
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js';
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
return i;
}
// ---------------------------------------------------------------------------
// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19).
// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine
// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans
// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent
// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le
// coffre personnel à la première ouverture si besoin.
// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants :
// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
// ---------------------------------------------------------------------------
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
function _secretsIcon() {
const s = document.createElement("span");
s.className = "coffres-icon-lock";
s.textContent = "\u{1F510}";
return s;
}
function _isOwnHome(vaultName) {
const me = (AuthManager.getUser() || {}).username;
return !!me && vaultName === `home-${me}`;
}
/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */
async function _buildSecretsNode(vaultName) {
const pair = document.createDocumentFragment();
const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]);
const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` });
item.addEventListener("click", async () => {
scrollTreeItemIntoView(item, false);
if (sub.classList.contains("collapsed")) {
if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName);
sub.classList.remove("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
sub.classList.add("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
await openCoffresView();
closeMobileSidebar();
});
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]);
actionBtn.addEventListener("click", (e) => {
e.stopPropagation();
const r = actionBtn.getBoundingClientRect();
_openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom);
});
item.appendChild(actionBtn);
pair.appendChild(item);
pair.appendChild(sub);
// Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue.
_fillSecretsNode(item, sub, vaultName).catch(() => {});
return pair;
}
/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */
async function _fillSecretsNode(item, sub, vaultName) {
const data = await getCoffresForSidebar();
item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove());
if (!data.disabled && data.personnel) {
if (!data.personnel.kek_configuree) {
const dot = document.createElement("span");
dot.className = "coffres-alert-dot";
dot.textContent = "\u26A0";
dot.title = t("coffres.kek_absente");
item.appendChild(dot);
} else {
item.appendChild(smallBadge(data.personnel.nb_secrets));
}
}
sub.innerHTML = "";
const coffres = (data.coffres || []).slice().sort((a, b) => {
const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1;
const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1;
return aPers - bPers || String(a.nom).localeCompare(String(b.nom));
});
coffres.forEach((c) => {
const me = (AuthManager.getUser() || {}).username;
const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`;
const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]);
child.addEventListener("click", async () => {
scrollTreeItemIntoView(child, false);
await openCoffresView(c.slug);
closeMobileSidebar();
});
child.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
sub.appendChild(child);
});
safeCreateIcons();
}
/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */
async function _refreshSecretsNodes() {
const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]'));
for (const item of items) {
const vaultName = item.dataset.vault;
const sub = item.nextElementSibling;
if (sub && sub.classList.contains("tree-children")) {
await _fillSecretsNode(item, sub, vaultName);
}
}
}
if (!window.__obsigateSecretsListener) {
window.__obsigateSecretsListener = true;
document.addEventListener("obsigate:coffres-changed", () => {
invalidateCoffresCache();
_refreshSecretsNodes().catch(() => {});
});
}
/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal.
* Menu propre, jamais ContextMenuManager (pas d'action de fichier). */
function _openSecretsMenu(x, y) {
document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove());
const menu = el("div", { class: "coffres-context-menu" }, []);
const entries = [
[t("coffres.detail"), () => openCoffresView()],
[t("coffres.new_secret"), () => openCoffresNewSecret()],
[t("coffres.acces"), () => openCoffresAcces()],
[t("coffres.journal"), () => openCoffresJournal()],
];
entries.forEach(([label, run]) => {
const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]);
b.addEventListener("click", async () => {
menu.remove();
await run();
closeMobileSidebar();
});
menu.appendChild(b);
});
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px";
menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px";
const closer = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("click", closer, true);
document.removeEventListener("keydown", onKey);
}
};
const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } };
setTimeout(() => {
document.addEventListener("click", closer, true);
document.addEventListener("keydown", onKey);
}, 0);
}
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
@@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
// Replace container content in a single batch operation to avoid flash
container.textContent = "";
container.appendChild(fragment);
@@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine
// du Home de l'utilisateur courant seulement, jamais sous un autre Home
// ni sous un vault de notes, et jamais comme un dossier physique.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
container.appendChild(fragment);
safeCreateIcons();
}