/* ObsiGate — Coffre Secrets (Phase 1c, issue #19, spec §8–§9). * * Nœud virtuel « Secrets » sous home- + vue Secrets dédiée. * * Règles de sécurité appliquées ici (jamais assouplies) : * - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur, * jamais un texte chiffré, jamais un nonce). Ce module n'affiche donc * jamais de valeur de secret après enregistrement : colonne valeur = * « •••••• » seulement, aucun bouton « afficher ». * - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un * journal console ou un log. Les variables contenant une valeur sont * effacées dès que l'opération est terminée (champ vidé, variable = null). * - Seule exception, voulue par le contrat backend : le jeton d'un * consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une * modale, avec un bouton Copier et un avertissement ; la modale fermée, * ils sont perdus. Ce ne sont pas des valeurs de secret du coffre. * - La copie manuelle à usage unique (admin seulement, voir * _copieManuelle) passe par un consommateur temporaire créé puis révoqué * immédiatement, avec distribution auditée ; il n'existe pas encore * d'endpoint de distribution par session utilisateur côté backend. * - Phase 2 (issue #20) : « Importer l'existant » migre les clés de * data/api_keys.json vers le coffre d'instance « ObsiGate interne », * en deux temps (import, puis purge confirmée). Aucune valeur ne * transite jamais par cette vue : l'aperçu et les résultats ne * contiennent que des noms et des comptes (has_value). * - La vue est rendue directement dans #content-area : elle ne passe ni * par TabManager.openNav (navigation de fichiers), ni par l'indexeur, * ni par la recherche, ni par le graphe. */ import { api, AuthManager } from './auth.js'; import { showToast, showConfirm, closeMobileSidebar } from './ui.js'; import { el } from './viewer.js'; import { safeCreateIcons } from './utils.js'; import { t } from './i18n.js'; // --------------------------------------------------------------------------- // Constantes (miroir exact du backend Phase 1b — ne pas deviner d'autres) // --------------------------------------------------------------------------- const TYPES_SECRET = ['api_key', 'token', 'password', 'certificate', 'autre']; const CIBLES_OCTROI = ['consommateur', 'utilisateur', 'executeur', 'workspace']; const PERIMETRES = ['personnel', 'partage', 'instance']; const NOM_SECRET_RE = /^[a-z0-9][a-z0-9_-]{2,127}$/; const SLUG_RE = /^[a-z0-9][a-z0-9_-]{2,63}$/; // --------------------------------------------------------------------------- // État + cache sidebar // --------------------------------------------------------------------------- let _coffresCache = null; let _coffresCacheAt = 0; const CACHE_MS = 30000; const viewState = { coffres: [], currentSlug: null, secrets: [], octrois: [], consommateurs: [], journal: [], filter: '', detail: null, // métadonnées d'un secret, jamais une valeur seq: 0, }; function me() { return (AuthManager.getUser() || {}).username || ''; } function isAdmin() { return (AuthManager.getUser() || {}).role === 'admin'; } function slugPersonnel() { return `personnel-${String(me()).toLowerCase()}`; } function notifyChanged() { invalidateCoffresCache(); document.dispatchEvent(new CustomEvent('obsigate:coffres-changed')); } export function invalidateCoffresCache() { _coffresCache = null; _coffresCacheAt = 0; } /** Liste des coffres visibles, mise en cache (sidebar + vue). */ export async function getCoffresForSidebar(force = false) { if (!force && _coffresCache && Date.now() - _coffresCacheAt < CACHE_MS) { return _coffresCache; } try { const coffres = await api('/api/coffres'); const personnel = (coffres || []).find( (c) => c.proprietaire === me() && c.perimetre === 'personnel' ) || null; _coffresCache = { coffres: coffres || [], personnel, kekConfiguree: personnel ? !!personnel.kek_configuree : false, disabled: false, }; } catch (err) { _coffresCache = { coffres: [], personnel: null, kekConfiguree: false, disabled: err && (err.message === 'secrets_desactives' || err.status === 503), error: err, }; } _coffresCacheAt = Date.now(); return _coffresCache; } /** Création paresseuse du coffre personnel, à la première ouverture de la * vue Secrets. Le slug est globalement unique côté backend, d'où * `personnel-` (et non un simple `personnel` partagé). * Sans KEK, le coffre est créé sans DEK (mode dégradé) : les métadonnées * existent, aucun secret n'est ajoutable tant que la KEK n'est pas là. */ async function ensurePersonalCoffre() { const data = await getCoffresForSidebar(true); if (data.disabled) return null; if (data.personnel) return data.personnel; try { const created = await api('/api/coffres', { method: 'POST', body: JSON.stringify({ slug: slugPersonnel(), nom: 'Personnel', perimetre: 'personnel', description: '', }), }); notifyChanged(); return created; } catch (err) { // Course : un autre onglet l'a créé entre-temps → relecture. if (err && err.status === 409) { const again = await getCoffresForSidebar(true); return again.personnel; } throw err; } } // --------------------------------------------------------------------------- // Petits utilitaires d'affichage (données via textContent uniquement) // --------------------------------------------------------------------------- function fmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } function fmtDateTime(iso) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) return String(iso); return d.toLocaleString(); } function joursJusquau(iso) { if (!iso) return null; const d = new Date(iso); if (Number.isNaN(d.getTime())) return null; return Math.ceil((d.getTime() - Date.now()) / 86400000); } function statutSecret(s) { if (s.expire) return t('coffres.expired'); const j = joursJusquau(s.expiration); if (j !== null && j <= 30) return t('coffres.expiring_soon'); return t('coffres.active'); } function text(tag, str, cls) { const n = document.createElement(tag); if (cls) n.className = cls; n.textContent = str == null ? '' : String(str); return n; } async function copyText(value, okMsg) { try { await navigator.clipboard.writeText(value); showToast(okMsg || t('coffres.copied'), 'success'); return true; } catch { showToast(t('coffres.copy_failed'), 'error'); return false; } } function genererValeur(longueur = 32) { if (!window.crypto || !window.crypto.getRandomValues) return null; const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*'; const buf = new Uint32Array(longueur); window.crypto.getRandomValues(buf); return Array.from(buf, (n) => chars[n % chars.length]).join(''); } // --------------------------------------------------------------------------- // Modales (classes existantes obsigate-modal-*, jamais d'innerHTML de données) // --------------------------------------------------------------------------- function openModal(title, bodyEl, footerEls) { const overlay = el('div', { class: 'obsigate-modal-overlay' }, []); const modal = el('div', { class: 'obsigate-modal', role: 'dialog' }, []); modal.setAttribute('aria-modal', 'true'); const header = el('div', { class: 'obsigate-modal-header' }, [ el('h3', { class: 'obsigate-modal-title' }, [document.createTextNode(title)]), ]); const body = el('div', { class: 'obsigate-modal-body' }, [bodyEl]); const footer = el('div', { class: 'obsigate-modal-footer' }, footerEls || []); modal.appendChild(header); modal.appendChild(body); modal.appendChild(footer); overlay.appendChild(modal); document.body.appendChild(overlay); setTimeout(() => overlay.classList.add('active'), 10); let closed = false; const close = () => { if (closed) return; closed = true; overlay.classList.remove('active'); document.removeEventListener('keydown', onKey); setTimeout(() => overlay.remove(), 200); }; const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', onKey); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); return { overlay, modal, close }; } function modalBtn(label, cls, onClick) { const b = el('button', { class: `modal-btn ${cls || ''}`.trim(), type: 'button' }, [ document.createTextNode(label), ]); b.addEventListener('click', onClick); return b; } function field(labelText, inputEl, hintText) { const wrap = el('div', { class: 'modal-form-group' }, []); if (labelText) { wrap.appendChild(el('label', { class: 'modal-label' }, [document.createTextNode(labelText)])); } wrap.appendChild(inputEl); if (hintText) { wrap.appendChild(el('div', { class: 'modal-hint' }, [document.createTextNode(hintText)])); } return wrap; } function textInput(value, placeholder) { const i = el('input', { class: 'modal-input', type: 'text', spellcheck: 'false' }, []); if (value) i.value = value; if (placeholder) i.placeholder = placeholder; return i; } function selectInput(options, value) { const s = el('select', { class: 'modal-select' }, []); options.forEach(([v, label]) => { const o = el('option', { value: v }, [document.createTextNode(label)]); if (v === value) o.selected = true; s.appendChild(o); }); return s; } /** Champ valeur : input mot de passe (+ œil) ou textarea pour les certificats. * La valeur n'est jamais écrite ailleurs que dans ce champ, et le champ * est vidé à la fermeture / à l'enregistrement. */ function valeurField(initialType) { let input = null; const wrap = el('div', { style: 'display:flex;gap:8px;align-items:flex-start' }, []); const build = (type) => { const old = input ? input.value : ''; if (input) input.remove(); if (type === 'certificate') { input = el('textarea', { class: 'modal-input', rows: '4', spellcheck: 'false' }, []); } else { input = el('input', { class: 'modal-input', type: 'password', spellcheck: 'false', autocomplete: 'new-password' }, []); } input.value = old; wrap.insertBefore(input, eyeBtn); eyeBtn.style.display = type === 'certificate' ? 'none' : ''; }; const eyeBtn = modalBtn('👁', '', () => { if (input && input.type === 'password') input.type = 'text'; else if (input) input.type = 'password'; }); eyeBtn.title = t('coffres.show_before_save'); // L'oeil doit etre enfant de wrap AVANT le premier build : insertBefore(input, eyeBtn) // leve sinon NotFoundError et la modale ne s'ouvre jamais (bug bouton Nouveau secret). wrap.appendChild(eyeBtn); build(initialType); return { wrap, setType: build, get value() { return input ? input.value : ''; }, clear() { if (input) input.value = ''; }, focus() { if (input) input.focus(); }, }; } function errorBox() { const p = el('p', { class: 'modal-error', style: 'display:none' }, []); return { el: p, show(msg) { p.textContent = msg || ''; p.style.display = msg ? '' : 'none'; }, }; } // --------------------------------------------------------------------------- // Vue Secrets — rendu dans #content-area, hors navigation de fichiers // --------------------------------------------------------------------------- function coffreActuel() { return viewState.coffres.find((c) => c.slug === viewState.currentSlug) || null; } function secretsFiltres() { const q = (viewState.filter || '').trim().toLowerCase(); if (!q) return viewState.secrets; return viewState.secrets.filter( (s) => s.nom.toLowerCase().includes(q) || (s.service || '').toLowerCase().includes(q) ); } function renderAlerts(host) { const alerts = []; viewState.secrets.forEach((s) => { const j = joursJusquau(s.expiration); if (s.expire) alerts.push(`${s.nom} : ${t('coffres.expired')}`); else if (j !== null && j <= 30) alerts.push(`${s.nom} : ${t('coffres.expiring_soon')}`); }); viewState.octrois.forEach((o) => { if (o.statut === 'actif' && !o.expiration) { alerts.push(`${t('coffres.octroi_no_expiration')} (${o.cible_type} ${o.cible_id})`); } }); if (!alerts.length) return; const box = el('div', { class: 'coffres-alerts' }, []); alerts.forEach((a) => box.appendChild(text('div', `⚠ ${a}`, 'coffres-alert'))); host.appendChild(box); } function renderDetail(host) { const s = viewState.detail; host.innerHTML = ''; if (!s) { host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty')); return; } host.appendChild(el('h3', {}, [document.createTextNode(s.nom)])); const rows = [ [t('coffres.reference'), s.reference], [t('coffres.type'), s.type], [t('coffres.service'), s.service || '—'], [t('coffres.note'), s.note || '—'], [t('coffres.status'), statutSecret(s)], [t('coffres.expires'), s.expiration ? fmtDate(s.expiration) : '—'], [t('coffres.version'), String(s.version)], [t('coffres.created'), fmtDateTime(s.cree_le)], [t('coffres.modified'), fmtDateTime(s.modifie_le)], [t('coffres.last_use'), s.derniere_utilisation ? fmtDateTime(s.derniere_utilisation) : '—'], [t('coffres.octrois'), String(s.octrois_actifs)], [t('coffres.value'), '••••••'], ]; const dl = el('dl', { class: 'coffres-dl' }, []); rows.forEach(([k, v]) => { dl.appendChild(el('dt', {}, [document.createTextNode(k)])); dl.appendChild(el('dd', {}, [document.createTextNode(v)])); }); host.appendChild(dl); const actions = el('div', { class: 'coffres-actions' }, []); actions.appendChild(modalBtn(t('coffres.copy_reference'), '', () => copyText(s.reference))); actions.appendChild(modalBtn(t('coffres.rotation'), 'primary', () => openRotationModal(s))); actions.appendChild(modalBtn(t('coffres.acces'), '', () => { document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' }); })); actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s))); if (!isAdmin()) { actions.lastChild.disabled = true; actions.lastChild.title = t('coffres.manual_copy_admin_only'); } actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s))); host.appendChild(actions); // Octrois de ce secret (dont les octrois coffre-wide, secret_id null). const oct = viewState.octrois.filter( (o) => o.secret_id === s.id || o.secret_id === null ); host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.octrois'))])); if (!oct.length) host.appendChild(text('p', '—', 'coffres-empty')); oct.forEach((o) => { const line = el('div', { class: 'coffres-line' }, []); line.appendChild(text('span', `${o.cible_type} ${o.cible_id} — ${o.statut}${o.secret_id ? '' : ` (${t('coffres.grant_all_coffre')})`} — ${o.expiration ? fmtDate(o.expiration) : t('coffres.octroi_no_expiration')}`)); const btn = modalBtn(t('coffres.revoke'), 'danger', async () => { await revoquerOctroi(o.id); }); line.appendChild(btn); host.appendChild(line); }); // Journal récent de ce secret (métadonnées seules, jamais de valeur). host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.journal'))])); const entries = viewState.journal.filter((e) => e.secret_ref === s.reference).slice(0, 20); if (!entries.length) host.appendChild(text('p', '—', 'coffres-empty')); entries.forEach((e) => { host.appendChild(text('div', `${fmtDateTime(e.timestamp)} — ${e.action} — ${e.username || ''} — ${e.resultat || ''}`, 'coffres-line')); }); } function renderTable(host) { host.innerHTML = ''; const secrets = secretsFiltres(); if (!secrets.length) { host.appendChild(text('p', t('coffres.no_secrets'), 'coffres-empty')); return; } const table = el('table', { class: 'coffres-table' }, []); const thead = el('thead', {}, []); const hr = el('tr', {}, []); [t('coffres.name'), t('coffres.service'), t('coffres.type'), t('coffres.modified'), t('coffres.expires'), t('coffres.octrois'), t('coffres.status'), t('coffres.value'), t('coffres.actions')].forEach((h) => { hr.appendChild(el('th', {}, [document.createTextNode(h)])); }); thead.appendChild(hr); table.appendChild(thead); const tbody = el('tbody', {}, []); secrets.forEach((s) => { const tr = el('tr', {}, []); const add = (v) => tr.appendChild(el('td', {}, [document.createTextNode(v)])); add(s.nom); add(s.service || '—'); add(s.type); add(fmtDate(s.modifie_le)); add(s.expiration ? fmtDate(s.expiration) : '—'); add(String(s.octrois_actifs)); add(statutSecret(s)); add('••••••'); const td = el('td', {}, []); td.appendChild(modalBtn(t('coffres.detail'), '', () => { viewState.detail = s; renderDetail(document.getElementById('coffres-detail')); })); tr.appendChild(td); tr.addEventListener('click', () => { viewState.detail = s; renderDetail(document.getElementById('coffres-detail')); }); tbody.appendChild(tr); }); table.appendChild(tbody); host.appendChild(table); } function renderAcces(host) { host.innerHTML = ''; host.appendChild(el('h3', {}, [document.createTextNode(t('coffres.acces'))])); const coffre = coffreActuel(); if (!coffre) return; // Consommateurs (admin seulement : la route l'exige). if (isAdmin()) { host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.consumers'))])); if (!viewState.consommateurs.length) host.appendChild(text('p', '—', 'coffres-empty')); viewState.consommateurs.forEach((c) => { const line = el('div', { class: 'coffres-line' }, []); line.appendChild(text('span', `${c.nom} — ${c.jeton_prefixe || ''} — ${c.statut} — ${fmtDate(c.cree_le)}`)); if (c.statut === 'actif') { line.appendChild(modalBtn(t('coffres.revoke'), 'danger', async () => { const ok = await showConfirm({ title: t('coffres.revoke'), message: `${c.nom} : ${t('coffres.confirm_revoke_consumer')}`, danger: true, }); if (!ok) return; await api(`/api/coffres/consommateurs/${encodeURIComponent(c.id)}`, { method: 'DELETE' }); notifyChanged(); await openCoffresView(coffre.slug, { panel: 'acces' }); })); } host.appendChild(line); }); host.appendChild(modalBtn(t('coffres.new_consumer'), 'primary', () => openConsumerModal())); // Consommateurs sans distribution dans le journal récent (limite 100 entrées). const vus = new Set( viewState.journal.filter((e) => e.action === 'distribution_ok').map((e) => e.consommateur) ); viewState.consommateurs.forEach((c) => { if (c.statut === 'actif' && !vus.has(c.nom)) { host.appendChild(text('div', `⚠ ${c.nom} : ${t('coffres.consumer_no_distribution')}`, 'coffres-alert')); } }); } // Création d'octroi, avec confirmation résumant exactement l'accès accordé. host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.create_octroi'))])); const cibleType = selectInput(CIBLES_OCTROI.map((v) => [v, v]), 'consommateur'); const cibleIdWrap = el('div', {}, []); const cibleIdText = textInput('', t('coffres.target_id')); const cibleIdSelect = selectInput( viewState.consommateurs.map((c) => [c.id, `${c.nom} (${c.jeton_prefixe || ''})`]), viewState.consommateurs[0] ? viewState.consommateurs[0].id : '' ); const syncCibleId = () => { cibleIdWrap.innerHTML = ''; cibleIdWrap.appendChild(cibleType.value === 'consommateur' ? cibleIdSelect : cibleIdText); }; cibleType.addEventListener('change', syncCibleId); syncCibleId(); const secretSelect = selectInput( [['', t('coffres.grant_all_coffre')]].concat( viewState.secrets.map((s) => [s.nom, s.nom]) ), '' ); const expirationInput = el('input', { class: 'modal-input', type: 'date' }, []); const form = el('div', { class: 'coffres-form' }, [ field(t('coffres.target_type'), cibleType), field(t('coffres.target_id'), cibleIdWrap), field(t('coffres.secret'), secretSelect), field(t('coffres.expiration'), expirationInput, t('coffres.octroi_default_90')), ]); form.appendChild(modalBtn(t('coffres.create_octroi'), 'primary', async () => { const cibleId = cibleType.value === 'consommateur' ? cibleIdSelect.value : cibleIdText.value.trim(); if (!cibleId) { showToast(t('coffres.target_id_required'), 'error'); return; } const resume = secretSelect.value ? `${cibleType.value} ${cibleId} → ${secretSelect.value}` : `${cibleType.value} ${cibleId} → ${t('coffres.grant_all_coffre')}`; const ok = await showConfirm({ title: t('coffres.create_octroi'), message: `${resume} — ${expirationInput.value ? fmtDate(expirationInput.value) : t('coffres.octroi_default_90')}`, }); if (!ok) return; const body = { cible_type: cibleType.value, cible_id: cibleId }; if (secretSelect.value) body.secret_nom = secretSelect.value; if (expirationInput.value) body.expiration = expirationInput.value; await api(`/api/coffres/${encodeURIComponent(coffre.slug)}/octrois`, { method: 'POST', body: JSON.stringify(body), }); showToast(t('coffres.octroi_created'), 'success'); notifyChanged(); await openCoffresView(coffre.slug, { panel: 'acces' }); })); host.appendChild(form); // Octrois du coffre. host.appendChild(el('h4', {}, [document.createTextNode(t('coffres.octrois'))])); if (!viewState.octrois.length) host.appendChild(text('p', '—', 'coffres-empty')); viewState.octrois.forEach((o) => { const secretNom = o.secret_id ? (viewState.secrets.find((s) => s.id === o.secret_id) || {}).nom || o.secret_id : t('coffres.grant_all_coffre'); const line = el('div', { class: 'coffres-line' }, []); line.appendChild(text('span', `${o.cible_type} ${o.cible_id} — ${secretNom} — ${o.statut} — ${o.expiration ? fmtDate(o.expiration) : t('coffres.octroi_no_expiration')}`)); if (o.statut === 'actif') { line.appendChild(modalBtn(t('coffres.revoke'), 'danger', () => revoquerOctroi(o.id))); } host.appendChild(line); }); } async function revoquerOctroi(id) { const ok = await showConfirm({ title: t('coffres.revoke'), message: t('coffres.confirm_revoke_octroi'), danger: true, }); if (!ok) return; await api(`/api/coffres/octrois/${encodeURIComponent(id)}`, { method: 'DELETE' }); showToast(t('coffres.octroi_revoked'), 'success'); notifyChanged(); await openCoffresView(viewState.currentSlug); } function renderJournal(host) { host.innerHTML = ''; host.appendChild(el('h3', {}, [document.createTextNode(t('coffres.journal'))])); if (!viewState.journal.length) host.appendChild(text('p', '—', 'coffres-empty')); viewState.journal.slice(0, 100).forEach((e) => { host.appendChild(text('div', `${fmtDateTime(e.timestamp)} — ${e.action} — ${e.username || ''} — ${e.secret_ref || ''} — ${e.resultat || ''}`, 'coffres-line')); }); } // --------------------------------------------------------------------------- // Modales : création, rotation, suppression, consommateur, coffre, KEK // --------------------------------------------------------------------------- function openCreateSecretModal(prefillSlug) { const coffre = coffreActuel(); const slug = prefillSlug || (coffre ? coffre.slug : null); if (!slug) return; const nomInput = textInput('', 'gitea-token'); const typeSelect = selectInput(TYPES_SECRET.map((v) => [v, v]), 'token'); const serviceInput = textInput('', 'Gitea'); const expirationInput = el('input', { class: 'modal-input', type: 'date' }, []); const noteInput = textInput('', ''); const valeur = valeurField('token'); const genBtn = modalBtn(t('coffres.generate'), '', () => { const v = genererValeur(32); if (v === null) { showToast(t('coffres.generate_unavailable'), 'error'); return; } // La valeur générée vit uniquement dans le champ, jusqu'à l'enregistrement. const input = valeur.wrap.querySelector('input, textarea'); if (input) input.value = v; }); const copyGenBtn = modalBtn(t('coffres.copy_generated'), '', async () => { // Seul moment où la valeur peut être copiée : avant l'enregistrement. await copyText(valeur.value); }); typeSelect.addEventListener('change', () => { valeur.setType(typeSelect.value); genBtn.disabled = typeSelect.value === 'certificate'; }); const err = errorBox(); const grantCheck = el('input', { type: 'checkbox' }, []); const grantCibleType = selectInput(CIBLES_OCTROI.map((v) => [v, v]), 'consommateur'); const grantCibleId = textInput('', t('coffres.target_id')); const grantWrap = el('div', { style: 'display:none' }, [ field(t('coffres.target_type'), grantCibleType), field(t('coffres.target_id'), grantCibleId), ]); grantCheck.addEventListener('change', () => { grantWrap.style.display = grantCheck.checked ? '' : 'none'; }); const body = el('div', {}, [ field(t('coffres.name'), nomInput, t('coffres.name_hint')), field(t('coffres.type'), typeSelect), field(t('coffres.service'), serviceInput), field(t('coffres.expiration'), expirationInput), field(t('coffres.note'), noteInput, t('coffres.never_in_note')), field(t('coffres.value'), valeur.wrap), el('div', { class: 'coffres-actions' }, [genBtn, copyGenBtn]), field(t('coffres.propose_octroi'), grantCheck), grantWrap, err.el, ]); const m = openModal(t('coffres.create_secret_title'), body, [ modalBtn(t('common.cancel'), '', () => { valeur.clear(); m.close(); }), modalBtn(t('coffres.save'), 'primary', async () => { const nom = nomInput.value.trim(); if (!NOM_SECRET_RE.test(nom)) { err.show(t('coffres.name_hint')); return; } if (!valeur.value) { err.show(t('coffres.value_required')); return; } try { const payload = { nom, type: typeSelect.value, valeur: valeur.value, service: serviceInput.value.trim(), note: noteInput.value.trim(), }; if (expirationInput.value) payload.expiration = expirationInput.value; await api(`/api/coffres/${encodeURIComponent(slug)}/secrets`, { method: 'POST', body: JSON.stringify(payload), }); // Effacement immédiat : la valeur ne survit pas à l'enregistrement. valeur.clear(); if (grantCheck.checked && grantCibleId.value.trim()) { try { await api(`/api/coffres/${encodeURIComponent(slug)}/octrois`, { method: 'POST', body: JSON.stringify({ cible_type: grantCibleType.value, cible_id: grantCibleId.value.trim(), secret_nom: nom, }), }); } catch (grantErr) { showToast(`${t('coffres.secret_saved_octroi_failed')} : ${grantErr.message}`, 'error'); } } m.close(); showToast(t('coffres.saved'), 'success'); notifyChanged(); await openCoffresView(slug); } catch (e) { err.show(e.message || t('common.error')); } }), ]); valeur.focus(); } function openRotationModal(s) { const valeur = valeurField(s.type); const err = errorBox(); const body = el('div', {}, [ field(t('coffres.name'), (() => { const i = textInput(s.nom, ''); i.disabled = true; return i; })()), field(t('coffres.value'), valeur.wrap), err.el, ]); const m = openModal(t('coffres.rotation_title'), body, [ modalBtn(t('common.cancel'), '', () => { valeur.clear(); m.close(); }), modalBtn(t('coffres.rotation'), 'primary', async () => { if (!valeur.value) { err.show(t('coffres.value_required')); return; } try { await api(`/api/coffres/${encodeURIComponent(s.reference.split('/')[0].slice(7))}/secrets/${encodeURIComponent(s.nom)}/rotation`, { method: 'POST', body: JSON.stringify({ valeur: valeur.value }), }); valeur.clear(); m.close(); showToast(t('coffres.rotated'), 'success'); notifyChanged(); await openCoffresView(viewState.currentSlug); } catch (e) { err.show(e.message || t('common.error')); } }), ]); valeur.focus(); } async function supprimerSecret(s) { const coffreSlug = s.reference.split('/')[0].slice(7); const casses = viewState.octrois.filter((o) => o.secret_id === s.id || o.secret_id === null).length; const ok = await showConfirm({ title: t('coffres.delete'), message: `${s.nom} — ${t('coffres.confirm_delete')} (${casses} ${t('coffres.octrois')})`, danger: true, }); if (!ok) return; await api(`/api/coffres/${encodeURIComponent(coffreSlug)}/secrets/${encodeURIComponent(s.nom)}`, { method: 'DELETE', }); showToast(t('coffres.deleted'), 'success'); viewState.detail = null; notifyChanged(); await openCoffresView(coffreSlug); } function openConsumerModal() { const nomInput = textInput('', 'spark'); const err = errorBox(); const body = el('div', {}, [field(t('coffres.name'), nomInput), err.el]); const m = openModal(t('coffres.new_consumer'), body, [ modalBtn(t('common.cancel'), '', () => m.close()), modalBtn(t('coffres.save'), 'primary', async () => { if (!nomInput.value.trim()) { err.show(t('coffres.name_required')); return; } try { const created = await api('/api/coffres/consommateurs', { method: 'POST', body: JSON.stringify({ nom: nomInput.value.trim() }), }); m.close(); // Le jeton n'est rendu qu'ici : modale à usage unique, jamais loggé. const tokenInput = textInput(created.jeton || '', ''); tokenInput.readOnly = true; const tokenBody = el('div', {}, [ field(t('coffres.token_once'), tokenInput, t('coffres.token_once_hint')), ]); const tm = openModal(t('coffres.new_consumer'), tokenBody, [ modalBtn(t('coffres.copy_reference'), '', async () => { await copyText(created.jeton || ''); }), modalBtn(t('common.close'), 'primary', () => tm.close()), ]); void tm; notifyChanged(); await openCoffresView(viewState.currentSlug, { panel: 'acces' }); } catch (e) { err.show(e.message || t('common.error')); } }), ]); } function openCreateCoffreModal() { const nomInput = textInput('', 'Homelab'); const slugInput = textInput('', 'homelab'); nomInput.addEventListener('input', () => { if (!slugInput.dataset.touched) { slugInput.value = nomInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, ''); } }); slugInput.addEventListener('input', () => { slugInput.dataset.touched = '1'; }); const perimetreOptions = PERIMETRES.filter((p) => p !== 'instance' || isAdmin()); const perimetreSelect = selectInput(perimetreOptions.map((v) => [v, v]), 'partage'); const descInput = textInput('', ''); const err = errorBox(); const body = el('div', {}, [ field(t('coffres.name'), nomInput), field(t('coffres.slug'), slugInput, t('coffres.slug_hint')), field(t('coffres.perimetre'), perimetreSelect), field(t('coffres.description'), descInput), err.el, ]); const m = openModal(t('coffres.create_coffre_title'), body, [ modalBtn(t('common.cancel'), '', () => m.close()), modalBtn(t('coffres.save'), 'primary', async () => { const slug = slugInput.value.trim().toLowerCase(); if (!SLUG_RE.test(slug)) { err.show(t('coffres.slug_hint')); return; } if (!nomInput.value.trim()) { err.show(t('coffres.name_required')); return; } try { const created = await api('/api/coffres', { method: 'POST', body: JSON.stringify({ slug, nom: nomInput.value.trim(), perimetre: perimetreSelect.value, description: descInput.value.trim(), }), }); m.close(); notifyChanged(); await openCoffresView(created.slug); } catch (e) { err.show(e.message || t('common.error')); } }), ]); } /** Copie manuelle à usage unique, admin seulement. * Le backend n'expose pas (encore) de distribution par session : on passe * par un consommateur temporaire créé, utilisé une fois, puis révoqué * immédiatement. La valeur est copiée au presse-papiers sans jamais être * affichée, puis la variable est effacée, même en cas d'échec. */ async function copieManuelle(s) { if (!isAdmin()) return; const ok = await showConfirm({ title: t('coffres.manual_copy'), message: t('coffres.manual_copy_confirm'), danger: true, }); if (!ok) return; let consommateurId = null; let valeur = null; try { const created = await api('/api/coffres/consommateurs', { method: 'POST', body: JSON.stringify({ nom: `copie-manuelle-${me()}-${Date.now()}` }), }); consommateurId = created.id; const jeton = created.jeton; const res = await fetch('/api/coffres/distribuer', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jeton}`, }, body: JSON.stringify({ reference: s.reference, contexte_type: 'copie_manuelle', contexte_id: `copie-manuelle-${Date.now()}`, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`); valeur = data.valeur; await copyText(valeur); } catch (e) { showToast(e.message || t('common.error'), 'error'); } finally { valeur = null; if (consommateurId) { try { await api(`/api/coffres/consommateurs/${encodeURIComponent(consommateurId)}`, { method: 'DELETE' }); } catch { /* révocation best-effort, l'octroi reste révocable depuis Accès */ } } } } /** Phase 2 — « Importer l'existant » (admin seulement, deux temps). * 1. Aperçu (métadonnées seules, jamais de valeur) puis import vers le * coffre d'instance. L'import ne supprime rien dans api_keys.json. * 2. Purge du clair, uniquement après confirmation explicite, et * uniquement pour les noms effectivement migrés. L'avertissement est * affiché avant l'import : les lecteurs actuels (IA, outils) lisent * encore api_keys.json, la purge les prive donc de la clé tant que * leur lecteur n'est pas branché sur le coffre (voir la documentation * de sauvegarde/restauration). */ async function importerExistant() { if (!isAdmin()) return; let apercu; try { apercu = await api('/api/coffres/migration/api-keys'); } catch (e) { showToast(e.message || t('common.error'), 'error'); return; } const candidats = apercu.candidats || []; const lignes = candidats.map((c) => { const etat = c.deja_existant ? t('coffres.import_already_exists') : (c.has_value ? t('coffres.import_has_value') : t('coffres.import_no_value')); return `${c.nom} (${c.service}) — ${etat}`; }); const body = el('div', {}, [ text('p', t('coffres.import_warning'), 'modal-hint'), ...lignes.map((ligne) => text('p', ligne, '')), ]); const m = openModal(t('coffres.import_title'), body, [ modalBtn(t('common.cancel'), '', () => m.close()), modalBtn(t('coffres.import_existing'), 'primary', async () => { let resultat; try { resultat = await api('/api/coffres/migration/api-keys/import', { method: 'POST', body: '{}' }); } catch (e) { showToast(e.message || t('common.error'), 'error'); return; } m.close(); const migres = resultat.migres || []; const resume = [ `${t('coffres.import_done')} — ${t('coffres.import_migrated')}: ${migres.length}, ${t('coffres.import_already_exists')}: ${(resultat.deja_existants || []).length}, ${t('coffres.import_no_value')}: ${(resultat.sans_valeur || []).length}`, ...migres.map((nom) => `${nom} — ${t('coffres.import_migrated')}`), ]; const rb = el('div', {}, resume.map((ligne) => text('p', ligne, ''))); const boutons = [modalBtn(t('common.close'), 'primary', async () => { rm.close(); await openCoffresView('obsigate-interne'); })]; if (migres.length) { boutons.unshift(modalBtn(t('coffres.import_purge'), 'danger', async () => { const ok = await showConfirm({ title: t('coffres.import_purge'), message: `${t('coffres.import_purge_confirm')} ${migres.join(', ')}`, danger: true, }); if (!ok) return; try { const purge = await api('/api/coffres/migration/api-keys/purger', { method: 'POST', body: JSON.stringify({ confirmer: true, noms: migres }), }); showToast(`${t('coffres.import_purged')} (${(purge.supprimes || []).length})`, 'success'); } catch (e) { showToast(e.message || t('common.error'), 'error'); } rm.close(); await openCoffresView('obsigate-interne'); })); } const rm = openModal(t('coffres.import_title'), rb, boutons); void rm; }), ]); } function openKekModal(mode) { if (!isAdmin()) return; if (mode === 'generer') { const body = el('div', {}, [field(t('coffres.kek_generate'), text('p', t('coffres.kek_generate_hint'), 'modal-hint'))]); const m = openModal(t('coffres.kek_generate'), body, [ modalBtn(t('common.cancel'), '', () => m.close()), modalBtn(t('coffres.kek_generate'), 'primary', async () => { const data = await api('/api/coffres/admin/generer-kek', { method: 'POST', body: '{}' }); m.close(); const tokenInput = textInput(data.kek || '', ''); tokenInput.readOnly = true; const tb = el('div', {}, [field(t('coffres.token_once'), tokenInput, t('coffres.kek_once_hint'))]); const tm = openModal(t('coffres.kek_generate'), tb, [ modalBtn(t('coffres.copy_reference'), '', async () => { await copyText(data.kek || ''); }), modalBtn(t('common.close'), 'primary', () => tm.close()), ]); void tm; }), ]); } else if (mode === 'rotation-kek') { const input = el('input', { class: 'modal-input', type: 'password', spellcheck: 'false', autocomplete: 'new-password' }, []); const err = errorBox(); const body = el('div', {}, [field(t('coffres.kek_new'), input, t('coffres.kek_rotation_hint')), err.el]); const m = openModal(t('coffres.kek_rotate'), body, [ modalBtn(t('common.cancel'), '', () => { input.value = ''; m.close(); }), modalBtn(t('coffres.kek_rotate'), 'primary', async () => { if (!input.value) { err.show(t('coffres.value_required')); return; } try { await api('/api/coffres/admin/rotation-kek', { method: 'POST', body: JSON.stringify({ nouvelle_kek: input.value }), }); input.value = ''; m.close(); showToast(t('coffres.kek_rotated'), 'success'); notifyChanged(); await openCoffresView(viewState.currentSlug); } catch (e) { err.show(e.message || t('common.error')); } }), ]); } } // --------------------------------------------------------------------------- // Ouverture de la vue // --------------------------------------------------------------------------- export async function openCoffresView(slug, options = {}) { const area = document.getElementById('content-area'); if (!area) return; const seq = ++viewState.seq; viewState.filter = ''; const dashboard = document.getElementById('dashboard-home'); if (dashboard) dashboard.remove(); area.innerHTML = ''; document.title = 'Secrets — ObsiGate'; const container = el('div', { class: 'coffres-view', id: 'coffres-view' }, []); container.appendChild(text('p', t('coffres.loading'), 'coffres-empty')); area.appendChild(container); closeMobileSidebar(); document.querySelectorAll('.tree-item.active').forEach((n) => n.classList.remove('active')); document.querySelector('[data-type="virtual-secrets"]')?.classList.add('active'); let coffres; try { coffres = await api('/api/coffres'); } catch (err) { if (seq !== viewState.seq) return; container.innerHTML = ''; container.appendChild(el('h2', {}, [document.createTextNode(t('coffres.title'))])); container.appendChild(text('p', err && err.message === 'secrets_desactives' ? t('coffres.disabled') : (err.message || t('common.error')), 'coffres-banner warn')); return; } // Création paresseuse du coffre personnel (voir ensurePersonalCoffre). const personnelExiste = (coffres || []).some( (c) => c.proprietaire === me() && c.perimetre === 'personnel' ); if (!personnelExiste) { try { await ensurePersonalCoffre(); coffres = await api('/api/coffres'); } catch { /* la vue reste utilisable sans coffre personnel */ } } if (seq !== viewState.seq) return; viewState.coffres = coffres || []; const wanted = slug || (viewState.coffres.find( (c) => c.proprietaire === me() && c.perimetre === 'personnel' ) || viewState.coffres[0] || {}).slug || null; viewState.currentSlug = wanted; const coffre = coffreActuel(); container.innerHTML = ''; container.appendChild(el('h2', {}, [document.createTextNode(t('coffres.title'))])); container.appendChild(text('p', t('coffres.subtitle'), 'coffres-subtitle')); // Bandeau KEK. const kekOk = coffre ? !!coffre.kek_configuree : false; const banner = el('div', { class: `coffres-banner ${kekOk ? '' : 'warn'}` }, []); banner.appendChild(text('span', kekOk ? t('coffres.kek_ok') : t('coffres.kek_absente'))); if (isAdmin()) { banner.appendChild(modalBtn(t('coffres.kek_generate'), '', () => openKekModal('generer'))); banner.appendChild(modalBtn(t('coffres.kek_rotate'), '', () => openKekModal('rotation-kek'))); } container.appendChild(banner); // Sélecteur de coffres (chips). const chips = el('div', { class: 'coffres-chips' }, []); viewState.coffres.forEach((c) => { const label = c.proprietaire === me() ? c.nom : `${c.nom} (${c.proprietaire})`; const chip = modalBtn(`${label} (${c.nb_secrets})`, c.slug === viewState.currentSlug ? 'primary' : '', async () => { await openCoffresView(c.slug); }); chip.classList.add('coffres-chip'); chips.appendChild(chip); }); chips.appendChild(modalBtn(`+ ${t('coffres.new_coffre')}`, '', () => openCreateCoffreModal())); container.appendChild(chips); if (!coffre) { container.appendChild(text('p', t('coffres.no_coffre'), 'coffres-empty')); return; } // Rotation DEK du coffre courant (gestionnaire seulement, backend 403 sinon). const toolbar = el('div', { class: 'coffres-actions' }, []); const search = textInput('', t('coffres.search_placeholder')); search.addEventListener('input', () => { viewState.filter = search.value; renderTable(document.getElementById('coffres-table')); }); toolbar.appendChild(search); toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug))); // Phase 2 : migration api_keys.json -> coffre d'instance (admin seulement). // Le bouton est visible quel que soit le coffre affiché : la cible est // toujours le coffre d'instance « ObsiGate interne ». if (isAdmin()) { toolbar.appendChild(modalBtn(t('coffres.import_existing'), '', () => importerExistant())); } toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => { const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true }); if (!ok) return; await api(`/api/coffres/${encodeURIComponent(coffre.slug)}/rotation-dek`, { method: 'POST', body: '{}' }); notifyChanged(); await openCoffresView(coffre.slug); })); container.appendChild(toolbar); // Données du coffre (métadonnées seules). try { const [secrets, octrois, journal] = await Promise.all([ api(`/api/coffres/${encodeURIComponent(coffre.slug)}/secrets`), api(`/api/coffres/${encodeURIComponent(coffre.slug)}/octrois`), api(`/api/coffres/${encodeURIComponent(coffre.slug)}/journal`), ]); viewState.secrets = secrets || []; viewState.octrois = octrois || []; viewState.journal = journal || []; if (isAdmin()) { try { viewState.consommateurs = await api('/api/coffres/consommateurs'); } catch { viewState.consommateurs = []; } } else { viewState.consommateurs = []; } } catch (err) { if (seq !== viewState.seq) return; container.appendChild(text('p', err.message || t('common.error'), 'coffres-banner warn')); return; } if (seq !== viewState.seq) return; viewState.detail = null; const alertsHost = el('div', {}, []); container.appendChild(alertsHost); renderAlerts(alertsHost); const tableHost = el('div', { id: 'coffres-table' }, []); container.appendChild(tableHost); renderTable(tableHost); const detailHost = el('div', { id: 'coffres-detail', class: 'coffres-detail' }, []); container.appendChild(detailHost); renderDetail(detailHost); const accesHost = el('div', { id: 'coffres-acces' }, []); container.appendChild(accesHost); renderAcces(accesHost); const journalHost = el('div', { id: 'coffres-journal' }, []); container.appendChild(journalHost); renderJournal(journalHost); safeCreateIcons(); if (options.panel === 'new') openCreateSecretModal(coffre.slug); else if (options.panel === 'acces') accesHost.scrollIntoView({ behavior: 'smooth' }); else if (options.panel === 'journal') journalHost.scrollIntoView({ behavior: 'smooth' }); } export function openCoffresNewSecret() { return openCoffresView(undefined, { panel: 'new' }); } export function openCoffresAcces() { return openCoffresView(undefined, { panel: 'acces' }); } export function openCoffresJournal() { return openCoffresView(undefined, { panel: 'journal' }); }