Files
ObsiGate/frontend/js/coffres.js
bruno 682a5030d3
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m19s
CI / test (pull_request) Failing after 1m47s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
[fix] Coffre Secrets : valeurField attache l'oeil a wrap avant insertBefore (NotFoundError bouton Nouveau secret)
2026-10-27 23:20:30 -04:00

1074 lines
42 KiB
JavaScript

/* ObsiGate — Coffre Secrets (Phase 1c, issue #19, spec §8–§9).
*
* Nœud virtuel « Secrets » sous home-<username> + 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-<utilisateur>` (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 */ }
}
}
}
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' });
}