1071 lines
42 KiB
JavaScript
1071 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');
|
|
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' });
|
|
}
|