diff --git a/frontend/js/coffres.js b/frontend/js/coffres.js new file mode 100644 index 0000000..e4862b1 --- /dev/null +++ b/frontend/js/coffres.js @@ -0,0 +1,1070 @@ +/* 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. + * - 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'); + 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 */ } + } + } +} + +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))); + 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' }); +}