Dashboard_Admin_Sys/GENERATEUR_CERTIFICATES/dashboard.html
Guillaume DENIS bfc88f4afc Portail Infra : CMDB, générateur certificats & mapper VLAN switch
Ajouts :
- Snapshots serveur de la base CMDB (auto + manuel, restauration)
- Import/Export CSV de l'inventaire

Préparation partage public :
- Données réelles (cmdb.db, snapshots, certs/clés privées) exclues via .gitignore
- Suppression des infos en dur (proxy, VLAN, configs switch exemples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 17:05:43 +02:00

1286 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Générateur de Certificats SSL</title>
<script src="vendor/forge.min.js"></script>
<script src="vendor/jszip.min.js"></script>
<style>
:root{
--bg:#0c0d10; --panel:#15171c; --panel2:#1d2026; --line:rgba(255,255,255,0.10);
--txt:#f2f3f5; --muted:#7c8189; --accent:#5b9cf5; --accent2:#4ecfa5;
--danger:#e66767; --warn:#e8b34b; --radius:12px;
}
*{box-sizing:border-box}
html{scrollbar-color:#33363e var(--bg)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#33363e;border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--bg);
color:var(--txt);min-height:100vh;-webkit-font-smoothing:antialiased;display:flex}
.wrap{flex:1;min-width:0;max-width:920px;margin:0 auto;padding:32px 20px 80px}
/* sidebar switcher projets */
aside.appside{width:250px;flex-shrink:0;background:var(--panel);border-right:1px solid var(--line);
padding:22px 14px;position:sticky;top:0;height:100vh;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
.appside .brand{display:flex;align-items:center;gap:10px;padding:0 10px 22px}
.appside .brand-mark{width:32px;height:32px;border-radius:9px;flex-shrink:0;
background:linear-gradient(135deg,var(--accent),#7c5cf5);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;color:#fff}
.appside .brand b{font-size:15px;letter-spacing:.4px}
.appside .brand small{display:block;font-size:10px;color:var(--muted);font-weight:400;letter-spacing:1px;text-transform:uppercase}
.psw{display:flex;flex-direction:column;gap:3px}
.psw-title{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;font-weight:600;padding:0 12px 7px}
.psw a{display:flex;align-items:center;gap:10px;text-decoration:none;padding:8px 10px;border-radius:9px;color:var(--muted);transition:background .12s}
.psw a:hover{background:var(--panel2)}
.psw a.active{background:rgba(91,156,245,.14)}
.psw a.active b{color:var(--accent)}
.psw-ico{width:30px;height:30px;border-radius:8px;background:var(--panel2);display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
.psw a.active .psw-ico{background:rgba(91,156,245,.22)}
.psw-txt{min-width:0}
.psw-txt b{display:block;font-size:13px;color:var(--txt);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.psw-txt small{display:block;font-size:10.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@media(max-width:760px){body{display:block}aside.appside{width:auto;height:auto;position:static;border-right:none;border-bottom:1px solid var(--line)}}
header{display:flex;align-items:center;gap:14px;margin-bottom:20px}
.logo{width:44px;height:44px;border-radius:12px;background:linear-gradient(135deg,var(--accent),#7c5cf5);
display:grid;place-items:center;font-size:22px;box-shadow:0 6px 20px rgba(91,156,245,.35)}
h1{font-size:20px;margin:0;font-weight:650}
header p{margin:2px 0 0;color:var(--muted);font-size:13px}
/* workspace bar */
.ws{display:flex;align-items:center;gap:12px;background:var(--panel);border:1px solid var(--line);
border-radius:12px;padding:12px 14px;margin-bottom:16px;flex-wrap:wrap}
.ws .info{flex:1;min-width:200px;font-size:13px}
.ws .info b{color:var(--accent2)}
.ws .info .none{color:var(--warn)}
.tabs{display:flex;gap:8px;margin-bottom:22px;background:var(--panel);padding:6px;border-radius:12px;border:1px solid var(--line)}
.tab{flex:1;padding:11px;border:none;background:transparent;color:var(--muted);font-weight:600;
border-radius:8px;cursor:pointer;font-size:14px;transition:.15s}
.tab.active{background:var(--panel2);color:var(--txt);box-shadow:0 2px 8px rgba(0,0,0,.3)}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:22px;margin-bottom:18px}
.steps{display:flex;justify-content:space-between;margin-bottom:26px;position:relative}
.steps:before{content:"";position:absolute;top:16px;left:6%;right:6%;height:2px;background:var(--line)}
.step{position:relative;z-index:1;text-align:center;flex:1}
.dot{width:34px;height:34px;border-radius:50%;background:var(--panel2);border:2px solid var(--line);
display:grid;place-items:center;margin:0 auto 6px;font-weight:700;font-size:14px;color:var(--muted);transition:.2s}
.step.active .dot{border-color:var(--accent);color:#fff;background:var(--accent);box-shadow:0 0 0 5px rgba(91,156,245,.15)}
.step.done .dot{border-color:var(--accent2);background:var(--accent2);color:#0c0d10}
.step small{color:var(--muted);font-size:12px}
.step.active small{color:var(--txt)}
label{display:block;font-size:13px;font-weight:600;margin:0 0 7px}
.hint{color:var(--muted);font-weight:400;font-size:12px}
input[type=text]{width:100%;padding:12px 14px;background:var(--panel2);border:1px solid var(--line);
border-radius:10px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
input[type=text]:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(91,156,245,.15)}
.row{display:flex;gap:10px}
.row input{flex:1}
button.btn{padding:12px 18px;border:none;border-radius:10px;font-weight:650;font-size:14px;cursor:pointer;
background:var(--accent);color:#fff;transition:.15s}
button.btn:hover{filter:brightness(1.1)}
button.btn.sec{background:var(--panel2);color:var(--txt);border:1px solid var(--line)}
button.btn.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}
button.btn.sm{padding:8px 12px;font-size:13px}
button.btn:disabled{opacity:.5;cursor:not-allowed}
.nav{display:flex;justify-content:space-between;margin-top:22px}
.chips{display:flex;flex-wrap:wrap;gap:8px;margin:14px 0}
.chip{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border-radius:20px;font-size:13px;
background:var(--panel2);border:1px solid var(--line)}
.chip.dns{border-color:#2c4a7a}.chip.ip{border-color:#2c6b52}
.chip b{color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase}
.chip .x{cursor:pointer;color:var(--muted);font-weight:700;margin-left:2px}
.chip .x:hover{color:var(--danger)}
.chip.locked .x{display:none}
.file{margin:14px 0}
.file .fh{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
.file .fh span{font-family:monospace;font-size:13px;color:var(--accent)}
pre{background:#101216;border:1px solid var(--line);border-radius:10px;padding:14px;overflow:auto;
font-size:12px;line-height:1.5;color:#b8bcc4;max-height:230px;margin:0;white-space:pre-wrap;word-break:break-all}
.dl{display:flex;flex-wrap:wrap;gap:10px;margin-top:16px}
.banner{padding:12px 14px;border-radius:10px;font-size:13px;margin-bottom:14px;display:none}
.banner.warn{background:rgba(232,179,75,.12);border:1px solid var(--warn);color:var(--warn);display:block}
.banner.err{background:rgba(230,103,103,.12);border:1px solid var(--danger);color:var(--danger);display:block}
.banner.ok{background:rgba(78,207,165,.12);border:1px solid var(--accent2);color:var(--accent2);display:block}
.spin{width:18px;height:18px;border:3px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;
display:inline-block;animation:sp .7s linear infinite;vertical-align:-3px;margin-right:8px}
@keyframes sp{to{transform:rotate(360deg)}}
.search{position:relative;margin-bottom:16px}
.search input{padding-left:40px}
.search svg{position:absolute;left:13px;top:12px}
.cert{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:10px;
display:flex;gap:16px;align-items:center}
.cert .body{flex:1;min-width:0}
.cert .cbtns{flex-shrink:0;display:flex;flex-direction:row;gap:8px;align-items:center}
.cert .cbtns .btn{white-space:nowrap}
.btn.ico{width:34px;height:34px;padding:0;border-radius:50%;font-style:normal;
font-weight:700;font-size:16px;display:grid;place-items:center}
.btn.ico.infoB{font-style:italic;font-family:Georgia,serif} /* « i » info stylisé */
.btn.ico.on{background:var(--accent);color:#fff;border-color:var(--accent)}
/* couleurs des actions */
.btn.ico.addB{color:#3b82f6;border-color:rgba(59,130,246,.45)}
.btn.ico.addB:hover{background:rgba(59,130,246,.14)}
.btn.ico.showB{color:#16a34a;border-color:rgba(22,163,74,.45)}
.btn.ico.showB:hover{background:rgba(22,163,74,.14)}
.btn.ico.archB{color:#f59e0b;border-color:rgba(245,158,11,.45)}
.btn.ico.archB:hover{background:rgba(245,158,11,.14)}
.sectitle{font-size:13px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;
margin:6px 2px 10px}
.cert.arch{border-color:#3a3450}
#aDate{width:100%;padding:11px 14px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;
color:var(--txt);font-size:14px;color-scheme:dark;margin-bottom:12px}
.btn.del{color:var(--danger);border-color:rgba(230,103,103,.4)}
.btn.del:hover{background:rgba(230,103,103,.12)}
.btn.dlB{color:#16a34a;border-color:rgba(22,163,74,.45)}
.btn.dlB:hover{background:rgba(22,163,74,.12)}
.btn svg{display:block}
.btn.delB svg{stroke:#e05656 !important} /* icône poubelle : trait rouge garanti */
.cert .body{cursor:pointer}
.achoice{display:flex;flex-direction:column;gap:10px}
.achoice .btn{padding:16px;font-size:15px}
.badge{font-size:11px;font-weight:700;padding:2px 9px;border-radius:12px;white-space:nowrap;vertical-align:middle}
.badge.ok{background:rgba(78,207,165,.15);color:var(--accent2);border:1px solid rgba(78,207,165,.4)}
.badge.soon{background:rgba(232,179,75,.15);color:var(--warn);border:1px solid rgba(232,179,75,.45)}
.badge.exp{background:rgba(230,103,103,.15);color:var(--danger);border:1px solid rgba(230,103,103,.5)}
.keyopts{display:flex;gap:8px;flex-wrap:wrap}
.keyopt{flex:1;min-width:110px;padding:10px 12px;border-radius:10px;background:var(--panel2);
border:1px solid var(--line);color:var(--txt);cursor:pointer;text-align:left;transition:.15s}
.keyopt b{display:block;font-size:13px}
.keyopt small{color:var(--muted);font-size:11px}
.keyopt.on{border-color:var(--accent);background:rgba(91,156,245,.12);box-shadow:0 0 0 3px rgba(91,156,245,.12)}
.keyopt.on b{color:var(--accent)}
.dngrid{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}
.dngrid label{font-weight:500;font-size:12px;margin-bottom:4px}
.dngrid input{padding:9px 12px}
.dngrid>div:last-child:nth-child(odd){grid-column:1/-1}
.cert h3{margin:0;font-size:15px;font-family:monospace;word-break:break-all}
.cert .meta{color:var(--muted);font-size:12px;margin-top:4px}
.cert .sans{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
/* modal */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(3px);
display:none;align-items:center;justify-content:center;padding:20px;z-index:50}
.modal.show{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:16px;width:100%;max-width:640px;
max-height:85vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.5)}
.modal .mhead{padding:18px 20px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
.modal .mhead h2{margin:0;font-size:16px;font-family:monospace;word-break:break-all}
.modal .mhead .sub{color:var(--muted);font-size:12px;margin-top:5px}
.modal .xClose{background:none;border:none;color:var(--muted);font-size:22px;cursor:pointer;line-height:1}
.modal .mbody{padding:12px 20px;overflow:auto}
.frow{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--line)}
.frow:last-child{border-bottom:none}
.frow .fico{font-size:18px}
.frow .fname{flex:1;font-family:monospace;font-size:13px;word-break:break-all}
.frow .fsz{color:var(--muted);font-size:12px;white-space:nowrap}
.modal .mfoot{padding:16px 20px;border-top:1px solid var(--line);display:flex;gap:10px;flex-wrap:wrap}
.modal .mfoot:empty{display:none}
.tag{font-size:11px;padding:3px 9px;border-radius:12px;background:#101216;border:1px solid var(--line);color:var(--muted)}
.empty{text-align:center;color:var(--muted);padding:40px 0;font-size:14px}
.hidden{display:none}
code{color:#b8bcc4}
</style>
</head>
<body>
<aside class="appside">
<div class="brand"><div class="brand-mark"></div><div><b>Outils Infra</b><small>portail</small></div></div>
<div class="psw">
<div class="psw-title">Projets</div>
<a href="../CMDB/cmdb.html"><div class="psw-ico"></div><div class="psw-txt"><b>CMDB</b><small>Machines, réseaux, salles</small></div></a>
<a class="active" href="dashboard.html"><div class="psw-ico">🔐</div><div class="psw-txt"><b>Générateur certificats</b><small>Clés RSA, CSR, SSL</small></div></a>
<a href="../GENERATEUR_SWITCH/switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
</div>
<div class="psw" style="margin-top:auto;padding-top:14px;border-top:1px solid var(--line)">
<a href="../CMDB/preferences.html"><div class="psw-ico"></div><div class="psw-txt"><b>Préférences</b><small>Types, tickets, réglages</small></div></a>
<div id="dbStatus" style="font-size:11px;color:var(--muted);padding:4px 8px;word-break:break-word;line-height:1.5;margin-top:6px">● Base CMDB</div>
</div>
</aside>
<div class="wrap">
<header>
<div class="logo">🔐</div>
<div>
<h1>Générateur de Certificats SSL</h1>
<p>Génère clé RSA, fichier .cnf et CSR — équivalent navigateur de <code>test.sh</code></p>
</div>
</header>
<!-- workspace bar -->
<div class="ws">
<div class="info" id="wsInfo">📂 Dossier de travail : <span class="none">initialisation…</span></div>
<button class="btn sm sec" id="prefsBtn">⚙ Réglages certificats</button>
<button class="btn sm" id="pickDir" title="Optionnel : écrire dans un vrai dossier disque au lieu du stockage navigateur">📂 Dossier disque…</button>
</div>
<div class="banner" id="wsBanner"></div>
<div class="tabs">
<button class="tab active" data-tab="gen"> Générer</button>
<button class="tab" data-tab="search">🔎 Certificats existants</button>
</div>
<!-- ================= GENERATE ================= -->
<section id="tab-gen">
<div class="card">
<div class="steps">
<div class="step active" data-s="1"><div class="dot">1</div><small>Nom serveur</small></div>
<div class="step" data-s="2"><div class="dot">2</div><small>SAN (DNS / IP)</small></div>
<div class="step" data-s="3"><div class="dot">3</div><small>Génération</small></div>
</div>
<div class="panel" data-p="1">
<label>Nom du serveur <span class="hint">(ex: monserveur-tss)</span></label>
<input type="text" id="server" placeholder="monserveur-tss" autocomplete="off">
<div class="banner" id="b1"></div>
<label style="margin-top:16px">Force de la clé</label>
<div class="keyopts" id="bits1"></div>
<p class="hint" style="margin-top:14px">Identité (DN) reprise depuis les <b>Préférences</b> (⚙ en haut). Dossier créé : <code id="dirPreview">&lt;nom&gt;.gemalto.com</code></p>
<div class="nav"><span></span><button class="btn" id="to2">Suivant →</button></div>
</div>
<div class="panel hidden" data-p="2">
<label>SAN supplémentaires <span class="hint">DNS ou IP — Entrée pour ajouter</span></label>
<div class="row">
<input type="text" id="sanInput" placeholder="alias1.gemalto.com ou 192.168.1.10" autocomplete="off">
<button class="btn sec" id="addSan">Ajouter</button>
</div>
<p class="hint" style="margin-top:12px">SAN par défaut (verrouillés, ajoutés par le script) :</p>
<div class="chips" id="chips"></div>
<div class="nav">
<button class="btn ghost" id="back1">← Retour</button>
<button class="btn" id="to3">Suivant →</button>
</div>
</div>
<div class="panel hidden" data-p="3">
<div class="banner ok" id="b3" style="display:none"></div>
<div id="genArea"></div>
<div id="result" class="hidden">
<div class="file">
<div class="fh"><span id="nCnf"></span><button class="btn ghost sm" data-cp="cnf">Copier</button></div>
<pre id="outCnf"></pre>
</div>
<div class="file">
<div class="fh"><span id="nKey"></span><button class="btn ghost sm" data-cp="key">Copier</button></div>
<pre id="outKey"></pre>
</div>
<div class="file">
<div class="fh"><span id="nCsr"></span><button class="btn ghost sm" data-cp="csr">Copier</button></div>
<pre id="outCsr"></pre>
</div>
<div class="dl">
<button class="btn" id="dlZip">📦 Télécharger le dossier (.zip)</button>
<button class="btn sec" data-dl="key">⬇ .key</button>
<button class="btn sec" data-dl="cnf">⬇ .cnf</button>
<button class="btn sec" data-dl="csr">⬇ .csr</button>
<button class="btn ghost" id="genNew">+ Nouveau</button>
</div>
<div id="ticketRow" style="display:none; text-align:right; margin-top:12px">
<button class="btn" onclick="openCertTicket()">🎫 Ouvrir un Ticket ↗</button>
</div>
</div>
</div>
</div>
</section>
<!-- ================= SEARCH ================= -->
<section id="tab-search" class="hidden">
<div class="card">
<div class="search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#7c8189" stroke-width="2">
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>
<input type="text" id="q" placeholder="Rechercher par nom de dossier ou fichier...">
</div>
<div id="list"></div>
</div>
</section>
</div>
<!-- modal certificats -->
<div class="modal" id="modal">
<div class="box">
<div class="mhead">
<div><h2 id="mTitle"></h2><div class="sub" id="mSub"></div></div>
<button class="xClose" id="mClose"></button>
</div>
<div class="mbody" id="mBody"></div>
<div class="mfoot" id="mFoot"></div>
</div>
</div>
<!-- modal préférences -->
<div class="modal" id="pmodal">
<div class="box" style="max-width:460px">
<div class="mhead">
<div><h2>⚙ Préférences</h2><div class="sub">Valeurs par défaut des certificats</div></div>
<button class="xClose" id="pClose"></button>
</div>
<div class="mbody">
<label>Domaine <span class="hint">(suffixe des dossiers, ex: gemalto.com)</span></label>
<input type="text" id="pDomain" autocomplete="off">
<label style="margin-top:14px">Email (emailAddress du cert)</label>
<input type="text" id="pEmail" autocomplete="off">
<label style="margin-top:14px">Force de clé par défaut</label>
<div class="keyopts" id="pBits"></div>
<label style="margin-top:14px">Identité (DN) par défaut</label>
<div class="dngrid" id="pDn"></div>
<label style="margin-top:14px">Lien ticket certificat <span class="hint">(proposé à la fin d'une génération)</span></label>
<input type="text" id="pTicket" autocomplete="off" placeholder="https://itsm.exemple.com/nouveau-ticket">
<div class="banner" id="pMsg"></div>
</div>
<div class="mfoot"><button class="btn" id="pSave">💾 Enregistrer</button></div>
</div>
</div>
<!-- modal archive -->
<div class="modal" id="amodal">
<div class="box" style="max-width:440px">
<div class="mhead">
<div><h2 id="aTitle"></h2><div class="sub" id="aSub"></div></div>
<button class="xClose" id="aClose"></button>
</div>
<div class="mbody" id="aBody"></div>
</div>
</div>
<script>
const PREF_KEY="ssl_prefs_v1";
const DEF_PREFS={domain:"gemalto.com", email:"mey.it@thalesgroup.com", bits:2048,
C:"", ST:"", L:"", O:"", OU:"", ticket_url:""};
const DN_FIELDS=[
{k:'C', label:'Pays (C)', ph:'FR'},
{k:'ST',label:'Région (ST)', ph:'Ile-de-France'},
{k:'L', label:'Ville (L)', ph:'Meudon'},
{k:'O', label:'Organisation (O)', ph:'Thales'},
{k:'OU',label:'Unité (OU)', ph:'IT'}
];
let PREFS=loadPrefs(), DOMAIN, EMAIL;
function loadPrefs(){try{return {...DEF_PREFS,...JSON.parse(localStorage.getItem(PREF_KEY))};}catch(e){return {...DEF_PREFS};}}
function applyPrefs(){DOMAIN=PREFS.domain;EMAIL=PREFS.email;}
applyPrefs();
function prefsDn(){return {C:PREFS.C,ST:PREFS.ST,L:PREFS.L,O:PREFS.O,OU:PREFS.OU};}
let state={server:"",dns:[],ip:[],locked:0,gen:null,bits:PREFS.bits,dn:prefsDn()};
let rootHandle=null; // dossier de travail (File System Access API)
const HAS_FS = 'showDirectoryPicker' in window;
const WORKDIR='LISTS_ALL_CERT'; // sous-dossier de travail (créé si absent)
const HAS_OPFS = !!(navigator.storage && navigator.storage.getDirectory);
let wsKind=''; // 'disk' | 'opfs' | ''
/* renvoie (créant au besoin) le sous-dossier LISTS_ALL_CERT du dossier choisi.
si le handle EST déjà LISTS_ALL_CERT, le renvoie tel quel. */
async function ensureWorkdir(h){
if(h.name===WORKDIR) return h;
return await h.getDirectoryHandle(WORKDIR,{create:true});
}
/* dossier de travail OPFS (Origin Private File System) : créé automatiquement,
AUCUN prompt / sélection. Persistant par origine. */
async function opfsWorkdir(){
const root=await navigator.storage.getDirectory();
return await root.getDirectoryHandle(WORKDIR,{create:true});
}
/* =============== backend serveur (cert_server.py) — écriture disque réelle =============== */
const API={on:false};
async function probeBackend(){
try{
const r=await fetch('api/ping',{cache:'no-store'});
if(r.ok){ const j=await r.json(); if(j&&j.ok){ API.on=true; return true; } }
}catch(e){}
return false;
}
async function apiPost(route,payload){
const r=await fetch('api/'+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const j=await r.json().catch(()=>({}));
if(!r.ok||!j.ok) throw new Error(j.error||('HTTP '+r.status));
return j;
}
async function apiWriteText(dir,files){ return apiPost('write',{dir,files}); }
async function apiWriteBin(path,blob){
const b64=await blobToB64(blob);
return apiPost('write_bin',{path,b64});
}
async function apiDeleteDir(dir,archives){ return apiPost('delete',{dir,archives:!!archives}); }
async function apiDeleteFile(dir,name){ return apiPost('delete_file',{dir,name}); }
async function apiDeleteArchives(dir){ return apiPost('delete_archives',{dir}); }
async function apiList(){ const r=await fetch('api/list',{cache:'no-store'}); return r.json(); }
function blobToB64(blob){
return new Promise((res,rej)=>{const r=new FileReader();
r.onload=()=>res(String(r.result).split(',')[1]||'');r.onerror=()=>rej(r.error);r.readAsDataURL(blob);});
}
/* URL statique d'un fichier du dossier de travail (téléchargement / lecture) */
function workUrl(dir,name){ return `${WORKDIR}/${encodeURIComponent(dir)}/${encodeURIComponent(name)}`; }
/* fabrique un objet fichier compatible (getFile) à partir d'une URL servie */
function serverFile(url,meta){
return { name:meta.name, ext:meta.ext, size:meta.size, mtime:meta.mtime,
handle:{ getFile: async()=>{ const resp=await fetch(url,{cache:'no-store'});
const blob=await resp.blob(); return new File([blob],meta.name,{lastModified:meta.mtime||Date.now()}); } } };
}
/* scan via serveur — même forme que scanDisk() */
async function scanServer(){
const j=await apiList();
const certs=(j.certs||[]).map(c=>{
const base=DOMAIN&&c.dir.endsWith('.'+DOMAIN)?c.dir.slice(0,-(DOMAIN.length+1)):c.dir;
const files=c.files.map(f=>serverFile(workUrl(c.dir,f.name),f));
return {name:base,dir:c.dir,files,date:c.date,info:null,handle:null};
});
const archives=(j.archives||[]).map(a=>({name:a.name,size:a.size,mtime:a.mtime,
handle:{ getFile: async()=>{ const resp=await fetch(`${WORKDIR}/archives/${encodeURIComponent(a.name)}`,{cache:'no-store'});
return new File([await resp.blob()],a.name,{lastModified:a.mtime||Date.now()}); } } }));
return {certs,archives};
}
/* =============== IndexedDB : mémorise le handle du dossier =============== */
function idb(){return new Promise((res,rej)=>{const r=indexedDB.open('ssl_ws',1);
r.onupgradeneeded=()=>r.result.createObjectStore('kv');
r.onsuccess=()=>res(r.result);r.onerror=()=>rej(r.error);});}
async function idbSet(k,v){const db=await idb();return new Promise((res,rej)=>{
const tx=db.transaction('kv','readwrite');tx.objectStore('kv').put(v,k);
tx.oncomplete=res;tx.onerror=()=>rej(tx.error);});}
async function idbGet(k){const db=await idb();return new Promise((res,rej)=>{
const tx=db.transaction('kv','readonly');const rq=tx.objectStore('kv').get(k);
rq.onsuccess=()=>res(rq.result);rq.onerror=()=>rej(rq.error);});}
async function verifyPerm(handle,write){
const opt={mode:write?'readwrite':'read'};
if(await handle.queryPermission(opt)==='granted')return true;
if(await handle.requestPermission(opt)==='granted')return true;
return false;
}
function wsBanner(cls,msg){const b=document.getElementById('wsBanner');
b.className='banner '+cls;b.textContent=msg;}
function renderWs(){
const i=document.getElementById('wsInfo');
if(rootHandle){
const tag = wsKind==='opfs' ? ' <span class="none">(stockage navigateur)</span>'
: wsKind==='disk' ? ' <span style="color:var(--accent2)">(disque)</span>' : '';
i.innerHTML=`📂 Dossier de travail : <b>${rootHandle.name}/</b>${tag}`;
}
else i.innerHTML=`📂 Dossier de travail : <span class="none">non sélectionné</span>`;
}
document.getElementById('pickDir').onclick=pickDir;
async function pickDir(){
if(!HAS_FS){wsBanner('err','⚠ File System Access API indisponible dans ce navigateur. Utilise Chrome/Edge (et sert la page via http://localhost). Sinon: fallback téléchargement .zip.');return;}
try{
const h=await window.showDirectoryPicker({mode:'readwrite',id:'ssl-ws',startIn:'downloads'});
if(!await verifyPerm(h,true)){wsBanner('err','Permission refusée sur le dossier.');return;}
const existed = await dirExists(h,WORKDIR);
rootHandle=await ensureWorkdir(h);wsKind='disk';await idbSet('root',rootHandle);
renderWs();wsBanner('ok',`✅ Dossier de travail : « ${h.name}/${WORKDIR}/ » ${existed?'lié':'créé'}. Les certificats y seront créés et recherchés.`);
}catch(e){ if(e.name!=='AbortError') wsBanner('err','Erreur: '+e.message+' (astuce: sert la page via http://localhost si tu es en file://).'); }
}
/* test présence sous-dossier sans le créer */
async function dirExists(parent,name){
try{ await parent.getDirectoryHandle(name); return true; }catch(e){ return false; }
}
/* au chargement : dossier LISTS_ALL_CERT prêt AUTOMATIQUEMENT (aucun prompt).
1) serveur cert_server.py → écriture disque réelle (recommandé)
2) dossier disque déjà autorisé (File System Access) → disque
3) OPFS (stockage navigateur) → repli
4) téléchargement .zip */
function wsInfoText(html){ document.getElementById('wsInfo').innerHTML='📂 Dossier de travail : '+html; }
(async()=>{
try{
// 1) serveur présent → écriture disque directe, zéro prompt
if(await probeBackend()){
wsKind='server'; rootHandle=null;
wsInfoText(`<b>${WORKDIR}/</b> <span style="color:var(--accent2)">(disque, serveur)</span>`);
wsBanner('ok',`✅ Serveur détecté. Certificats et archives écrits sur le disque dans ${WORKDIR}/ automatiquement.`);
return;
}
// 2) dossier disque déjà choisi + autorisé précédemment
const h=await idbGet('root');
if(h && h.queryPermission && await h.queryPermission({mode:'readwrite'})==='granted'){
rootHandle=await ensureWorkdir(h);
if(rootHandle!==h) await idbSet('root',rootHandle);
wsKind='disk'; renderWs(); return;
}
// 3) OPFS auto (stockage navigateur)
if(HAS_OPFS){
rootHandle=await opfsWorkdir(); wsKind='opfs'; renderWs();
wsBanner('warn',`⚠ Pas de serveur (python -m http.server est lecture seule). Fichiers dans le stockage navigateur — récupère-les via ⬇ / .zip. Pour écrire un VRAI dossier disque : lance « python cert_server.py ».`);
return;
}
// 4) rien → téléchargement
wsInfoText('<span class="none">téléchargement .zip</span>');
wsBanner('warn',' Écriture disque indisponible → fichiers téléchargés (.zip). Pour écrire sur disque : « python cert_server.py ».');
}catch(e){
try{ if(HAS_OPFS){ rootHandle=await opfsWorkdir(); wsKind='opfs'; renderWs(); return; } }catch(_){}
wsInfoText('<span class="none">téléchargement .zip</span>');
wsBanner('warn',' Stockage auto indisponible : '+e.message+' → téléchargement (.zip).');
}
})();
/* =============== Index des certificats partagé avec la CMDB =============== */
function readCertIndex(){ try{ return JSON.parse(localStorage.getItem('cert_index')||'[]'); }catch(e){ return []; } }
function writeCertIndex(arr){ try{ localStorage.setItem('cert_index',JSON.stringify(arr)); }catch(e){} }
function upsertCertIndex(g){
const arr=readCertIndex().filter(c=>c.dir!==g.dir);
arr.push({name:g.name,dir:g.dir,date:g.date,dns:g.dns||[],ip:g.ip||[]});
writeCertIndex(arr);
}
/* Reconstruit l'index complet (DNS/IP lus dans les .cnf) → localStorage 'cert_index'. */
async function publishCertIndex(certs){
const out=[];
for(const c of certs){
let info={dns:[],ip:[]};
try{ info=await loadInfo(c); }catch(e){}
out.push({name:c.name,dir:c.dir,date:c.date,dns:info.dns||[],ip:info.ip||[]});
}
writeCertIndex(out);
}
/* Handoff CMDB → recherche : #find=<dossier> ouvre l'onglet Certificats + filtre.
Sur 'load' : les handlers d'onglets sont déjà attachés (click effectif). */
window.addEventListener('load', ()=>{
// version de la base CMDB (partagée via localStorage)
try{ const s=localStorage.getItem('cmdb_db_status'); const el=document.getElementById('dbStatus'); if(s&&el) el.textContent=s; }catch(e){}
const m=(location.hash||'').match(/[#&]find=([^&]+)/);
if(!m) return;
let q=''; try{ q=decodeURIComponent(m[1]); }catch(e){}
const qi=document.getElementById('q'); if(qi) qi.value=q;
history.replaceState(null,'',location.pathname+location.search);
const searchTab=document.querySelector('.tab[data-tab="search"]');
if(searchTab) searchTab.click();
// Le workspace (serveur / disque / OPFS) s'initialise en asynchrone : le 1er
// renderList peut tomber avant qu'il soit prêt (liste vide). On relance dès qu'il
// est disponible, sinon la recherche n'aboutit qu'après une frappe manuelle.
let tries=0;
const iv=setInterval(()=>{
if(API.on || rootHandle){ clearInterval(iv); renderList(); }
else if(++tries>60){ clearInterval(iv); } // ~6 s max
},100);
});
/* =============== force de clé (radios) =============== */
const KEY_OPTS=[
{bits:2048,label:'RSA 2048',note:'standard, rapide'},
{bits:3072,label:'RSA 3072',note:'renforcé'},
{bits:4096,label:'RSA 4096',note:'max sécurité, plus lent'}
];
function renderKeyOpts(container,selected,onPick){
container.innerHTML='';
KEY_OPTS.forEach(o=>{
const b=document.createElement('button');
b.type='button';b.className='keyopt'+(o.bits===selected?' on':'');
b.innerHTML=`<b>${o.label}</b><small>${o.note}</small>`;
b.onclick=()=>{selected=o.bits;[...container.children].forEach(x=>x.classList.remove('on'));b.classList.add('on');onPick(o.bits);};
container.appendChild(b);
});
}
renderKeyOpts(document.getElementById('bits1'), state.bits, v=>state.bits=v);
/* =============== champs DN (C/ST/L/O/OU) =============== */
function renderDn(container,vals){
container.innerHTML='';
DN_FIELDS.forEach(f=>{
const w=document.createElement('div');
w.innerHTML=`<label>${f.label}</label><input type="text" data-dn="${f.k}" placeholder="${f.ph}" autocomplete="off" value="${(vals[f.k]||'').replace(/"/g,'&quot;')}">`;
container.appendChild(w);
});
}
function readDn(container){
const o={};
container.querySelectorAll('[data-dn]').forEach(i=>o[i.dataset.dn]=i.value.trim());
return o;
}
/* =============== préférences =============== */
document.getElementById('prefsBtn').onclick=()=>{
document.getElementById('pDomain').value=PREFS.domain;
document.getElementById('pEmail').value=PREFS.email;
document.getElementById('pTicket').value=PREFS.ticket_url||'';
renderKeyOpts(document.getElementById('pBits'),PREFS.bits,v=>PREFS._bits=v);
PREFS._bits=PREFS.bits;
renderDn(document.getElementById('pDn'),PREFS);
document.getElementById('pMsg').className='banner';document.getElementById('pMsg').textContent='';
document.getElementById('pmodal').classList.add('show');
};
document.getElementById('pClose').onclick=()=>document.getElementById('pmodal').classList.remove('show');
document.getElementById('pmodal').onclick=e=>{if(e.target.id==='pmodal')document.getElementById('pmodal').classList.remove('show');};
document.getElementById('pSave').onclick=()=>{
const d=document.getElementById('pDomain').value.trim(), em=document.getElementById('pEmail').value.trim();
const m=document.getElementById('pMsg');
if(!d){m.className='banner err';m.textContent='Domaine requis.';return;}
const dn=readDn(document.getElementById('pDn'));
let tkUrl=document.getElementById('pTicket').value.trim();
if(tkUrl && !/^https?:\/\//i.test(tkUrl)) tkUrl='https://'+tkUrl;
PREFS={domain:d, email:em, bits:PREFS._bits||PREFS.bits, ...dn, ticket_url:tkUrl};
localStorage.setItem(PREF_KEY,JSON.stringify({domain:d,email:em,bits:PREFS.bits,...dn,ticket_url:tkUrl}));
applyPrefs();
state.bits=PREFS.bits; state.dn=prefsDn();
renderKeyOpts(document.getElementById('bits1'), state.bits, v=>state.bits=v);
document.getElementById('dirPreview').textContent=`${srv.value.trim()||'<nom>'}.${DOMAIN}`;
m.className='banner ok';m.textContent='✅ Préférences enregistrées.';
setTimeout(()=>document.getElementById('pmodal').classList.remove('show'),700);
};
/* =============== tabs =============== */
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>{
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
t.classList.add('active');
const g=t.dataset.tab==='gen';
document.getElementById('tab-gen').classList.toggle('hidden',!g);
document.getElementById('tab-search').classList.toggle('hidden',g);
if(!g) renderList();
});
/* =============== stepper =============== */
function goStep(n){
document.querySelectorAll('.step').forEach(s=>{const i=+s.dataset.s;
s.classList.toggle('active',i===n);s.classList.toggle('done',i<n);});
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('hidden',+p.dataset.p!==n));
}
/* =============== step 1 =============== */
const srv=document.getElementById('server');
srv.oninput=()=>{const v=srv.value.trim();
document.getElementById('dirPreview').textContent=`${v||'<nom>'}.${DOMAIN}`;};
document.getElementById('to2').onclick=()=>{
const v=srv.value.trim(); const b=document.getElementById('b1');
if(!v){b.className='banner err';b.textContent='❌ Vous devez fournir un nom de serveur.';return;}
b.className='banner';b.textContent='';
state.server=v;
state.dns=[`${v}.${DOMAIN}`, v]; // défauts du script
state.ip=[]; state.locked=2;
renderChips(); resetGenArea();
goStep(2);
};
/* =============== step 2 : SAN =============== */
const IP_RE=/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
function renderChips(){
const c=document.getElementById('chips');c.innerHTML='';
state.dns.forEach((d,i)=>c.appendChild(chip('DNS','dns',d,i<state.locked,()=>{state.dns.splice(i,1);renderChips();})));
state.ip.forEach((p,i)=>c.appendChild(chip('IP','ip',p,false,()=>{state.ip.splice(i,1);renderChips();})));
}
function chip(lbl,cls,val,locked,onDel){
const e=document.createElement('span');e.className='chip '+cls+(locked?' locked':'');
e.innerHTML=`<b>${lbl}</b>${val}<span class="x">✕</span>`;
e.querySelector('.x').onclick=onDel;return e;
}
function addSan(){
const inp=document.getElementById('sanInput');const v=inp.value.trim();if(!v)return;
if(IP_RE.test(v)) state.ip.push(v); else state.dns.push(v);
inp.value='';renderChips();inp.focus();
}
document.getElementById('addSan').onclick=addSan;
document.getElementById('sanInput').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();addSan();}};
document.getElementById('back1').onclick=()=>goStep(1);
document.getElementById('to3').onclick=()=>goStep(3);
/* =============== .cnf builder (identique au script) =============== */
function buildCnf(server,dns,ip,dn){
dn=dn||{};
let d='';
['C','ST','L','O','OU'].forEach(k=>{if(dn[k]) d+=`${k} = ${dn[k]}\n`;});
let s=`[req]\nprompt=no\ndistinguished_name = dn\nreq_extensions = ext\n\n`+
`[dn]\n${d}CN = ${server}.${DOMAIN}\nemailAddress = ${EMAIL}\n\n`+
`[ext]\nsubjectAltName = @alt_names\n\n[alt_names]\n`;
dns.forEach((d,i)=>s+=`DNS.${i+1} = ${d}\n`);
ip.forEach((p,i)=>s+=`IP.${i+1} = ${p}\n`);
return s;
}
/* =============== génération =============== */
function resetGenArea(){
const area=document.getElementById('genArea');
area.classList.remove('hidden');
document.getElementById('result').classList.add('hidden');
document.getElementById('b3').style.display='none';
const dest = API.on ? `<b style="color:var(--accent2)">${WORKDIR}/${state.server}.${DOMAIN}/</b> (disque)`
: rootHandle ? `<b style="color:var(--accent2)">${rootHandle.name}/${state.server}.${DOMAIN}/</b>`
: `téléchargement .zip (aucun dossier lié)`;
area.innerHTML=`<p class="hint">Récap — clé RSA <b>${state.bits}</b>, CN = <code>${state.server}.${DOMAIN}</code>, email <code>${EMAIL}</code>.<br>Destination : ${dest}</p>
<div class="nav"><button class="btn ghost" id="back2">← Retour</button><button class="btn" id="doGen">⚡ Générer les fichiers</button></div>`;
document.getElementById('back2').onclick=()=>goStep(2);
document.getElementById('doGen').onclick=doGen;
}
function doGen(){
state.dn=prefsDn(); // DN repris des Préférences
const area=document.getElementById('genArea');
area.innerHTML=`<p class="hint"><span class="spin"></span>Génération clé RSA ${state.bits} + CSR…</p>`;
setTimeout(()=>runGen(area),50);
}
function genErr(area,msg){
area.innerHTML=`<div class="banner err" style="display:block">${msg}</div>
<div class="nav"><button class="btn ghost" id="back2b">← Retour</button><button class="btn" id="doGen2">↻ Réessayer</button></div>`;
document.getElementById('back2b').onclick=()=>goStep(2);
document.getElementById('doGen2').onclick=doGen;
}
/* base64url (composant JWK) → forge BigInteger */
function jwkBig(s){
s = s.replace(/-/g,'+').replace(/_/g,'/');
while(s.length % 4) s += '=';
const bytes = forge.util.decode64(s);
return new forge.jsbn.BigInteger(forge.util.createBuffer(bytes).toHex(), 16);
}
/* Génère une paire RSA sans figer le PC :
- WebCrypto natif (rapide, non bloquant, même en 4096) → converti en clé forge ;
- repli : génération forge asynchrone (par étapes) si WebCrypto indisponible. */
async function genKeyPair(bits){
if(window.crypto && crypto.subtle){
try{
const kp = await crypto.subtle.generateKey(
{ name:'RSASSA-PKCS1-v1_5', modulusLength:bits,
publicExponent:new Uint8Array([1,0,1]), hash:'SHA-256' },
true, ['sign','verify']);
const j = await crypto.subtle.exportKey('jwk', kp.privateKey);
const privateKey = forge.pki.rsa.setPrivateKey(
jwkBig(j.n), jwkBig(j.e), jwkBig(j.d),
jwkBig(j.p), jwkBig(j.q), jwkBig(j.dp), jwkBig(j.dq), jwkBig(j.qi));
return { privateKey, publicKey: forge.pki.rsa.setPublicKey(jwkBig(j.n), jwkBig(j.e)) };
}catch(err){ console.warn('WebCrypto indisponible, repli forge:', err); }
}
return await new Promise((res,rej)=>
forge.pki.rsa.generateKeyPair({ bits }, (er,kp)=> er?rej(er):res(kp)));
}
async function runGen(area){
if(typeof forge==='undefined' || !forge.pki){
genErr(area,'❌ Librairie <b>node-forge</b> non chargée (vendor/forge.min.js introuvable). Vérifie que le dossier <code>vendor/</code> est bien servi.');
return;
}
try{
// Génération non bloquante (WebCrypto natif) — plus de gel/RAM en RSA 4096.
const keys=await genKeyPair(state.bits);
const cnf=buildCnf(state.server,state.dns,state.ip,state.dn);
const csr=forge.pki.createCertificationRequest();
csr.publicKey=keys.publicKey;
const subj=[];
['C','ST','L','O','OU'].forEach(k=>{if(state.dn[k]) subj.push({shortName:k,value:state.dn[k]});});
subj.push({name:'commonName',value:`${state.server}.${DOMAIN}`});
subj.push({name:'emailAddress',value:EMAIL});
csr.setSubject(subj);
const altNames=[...state.dns.map(d=>({type:2,value:d})),...state.ip.map(p=>({type:7,ip:p}))];
csr.setAttributes([{name:'extensionRequest',extensions:[{name:'subjectAltName',altNames}]}]);
csr.sign(keys.privateKey,forge.md.sha256.create());
const g={name:state.server, dir:`${state.server}.${DOMAIN}`,
key:forge.pki.privateKeyToPem(keys.privateKey), cnf,
csr:forge.pki.certificationRequestToPem(csr),
dns:[...state.dns], ip:[...state.ip], date:new Date().toISOString()};
state.gen=g;
let msg=`✅ Fichiers générés (${g.dir}). DNS: ${g.dns.join(', ')||'—'} | IP: ${g.ip.join(', ')||'—'}`;
if(API.on){
try{ await apiWriteText(g.dir,{[`${g.name}.key`]:g.key,[`${g.name}.cnf`]:g.cnf,[`${g.name}.csr`]:g.csr});
msg=`✅ Écrits sur le disque : ${WORKDIR}/${g.dir}/`; }
catch(e){ msg=`⚠ Génération OK mais écriture serveur échouée: ${e.message}. Utilise le .zip.`; }
}else if(rootHandle){
try{ await writeToDisk(g); msg=`✅ Écrits dans ${rootHandle.name}/${g.dir}/${wsKind==='opfs'?' (stockage navigateur — bouton ⬇ pour récupérer)':''}`; }
catch(e){ msg=`⚠ Génération OK mais écriture échouée: ${e.message}. Utilise le .zip.`; }
}
showResult(g,area,msg);
upsertCertIndex(g); // publie le cert (dns/ip) pour la CMDB
}catch(e){ genErr(area,'❌ Erreur génération: '+e.message); }
}
/* Lien du ticket certificat : préférences locales du générateur (fiable) puis,
en repli, valeur partagée par la CMDB via localStorage 'cmdb_tickets'. */
function certTicketUrl(){
if(PREFS && PREFS.ticket_url) return PREFS.ticket_url;
try{ const m=JSON.parse(localStorage.getItem('cmdb_tickets')||'{}'); return (m.certificat&&m.certificat.url)||''; }
catch(e){ return ''; }
}
function openCertTicket(){
const url=certTicketUrl();
if(url) window.open(url,'_blank');
else alert('Aucun lien de ticket configuré (Préférences ⚙ → « Lien ticket certificat »).');
}
async function writeToDisk(g){
const dir=await rootHandle.getDirectoryHandle(g.dir,{create:true});
await writeFile(dir,`${g.name}.key`,g.key);
await writeFile(dir,`${g.name}.cnf`,g.cnf);
await writeFile(dir,`${g.name}.csr`,g.csr);
}
async function writeFile(dir,name,content){
const fh=await dir.getFileHandle(name,{create:true});
const w=await fh.createWritable();await w.write(content);await w.close();
}
function showResult(g,area,msg){
area.classList.add('hidden');
document.getElementById('result').classList.remove('hidden');
document.getElementById('nCnf').textContent=`${g.dir}/${g.name}.cnf`;
document.getElementById('nKey').textContent=`${g.dir}/${g.name}.key`;
document.getElementById('nCsr').textContent=`${g.dir}/${g.name}.csr`;
document.getElementById('outCnf').textContent=g.cnf;
document.getElementById('outKey').textContent=g.key;
document.getElementById('outCsr').textContent=g.csr;
const b=document.getElementById('b3');b.style.display='block';b.className='banner ok';b.textContent=msg;
const tr=document.getElementById('ticketRow'); if(tr) tr.style.display=certTicketUrl()?'block':'none';
}
/* copy / download simple */
function fileMap(g){return{key:[`${g.name}.key`,g.key],cnf:[`${g.name}.cnf`,g.cnf],csr:[`${g.name}.csr`,g.csr]};}
document.addEventListener('click',e=>{
const cp=e.target.dataset.cp;
if(cp&&state.gen){navigator.clipboard.writeText(state.gen[cp]);e.target.textContent='Copié ✓';setTimeout(()=>e.target.textContent='Copier',1200);}
const dl=e.target.dataset.dl;
if(dl&&state.gen){const[n,c]=fileMap(state.gen)[dl];download(n,c);}
});
function download(name,content){
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([content],{type:'application/octet-stream'}));
a.download=name;a.click();URL.revokeObjectURL(a.href);
}
async function downloadZip(g){
const zip=new JSZip();const f=zip.folder(g.dir);
f.file(`${g.name}.key`,g.key);f.file(`${g.name}.cnf`,g.cnf);f.file(`${g.name}.csr`,g.csr);
const blob=await zip.generateAsync({type:'blob'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${g.dir}.zip`;a.click();URL.revokeObjectURL(a.href);
}
document.getElementById('dlZip').onclick=()=>downloadZip(state.gen);
document.getElementById('genNew').onclick=()=>{
state={server:"",dns:[],ip:[],locked:0,gen:null,bits:PREFS.bits,dn:prefsDn()};
srv.value='';document.getElementById('dirPreview').textContent=`<nom>.${DOMAIN}`;
document.getElementById('b1').textContent='';document.getElementById('b1').className='banner';
renderKeyOpts(document.getElementById('bits1'), state.bits, v=>state.bits=v);
goStep(1);
};
/* =============== recherche (scan disque réel) =============== */
document.getElementById('q').oninput=renderList;
function parseCnf(txt){
const dns=[],ip=[];
txt.split('\n').forEach(l=>{
let m=l.match(/^\s*DNS\.\d+\s*=\s*(.+?)\s*$/); if(m){dns.push(m[1]);return;}
m=l.match(/^\s*IP\.\d+\s*=\s*(.+?)\s*$/); if(m)ip.push(m[1]);
});
return {dns,ip};
}
const ARCH_EXT=['zip','tar','gz','tgz','tar.gz','7z','xz','bz2'];
function isArchive(n){const l=n.toLowerCase();return ARCH_EXT.some(e=>l.endsWith('.'+e));}
async function scanDisk(){
// 1) collecte dossiers *.gemalto.com + archives (racine + dossier archives/)
const dirs=[], archives=[];
for await (const [name,handle] of rootHandle.entries()){
if(handle.kind==='directory' && name.endsWith('.'+DOMAIN)) dirs.push([name,handle]);
else if(handle.kind==='directory' && name==='archives'){
for await (const [an,ah] of handle.entries())
if(ah.kind==='file') archives.push({name:an,handle:ah,size:0,mtime:null});
}
else if(handle.kind==='file' && isArchive(name)) archives.push({name,handle,size:0,mtime:null});
}
await Promise.all(archives.map(async a=>{try{const f=await a.handle.getFile();a.size=f.size;a.mtime=f.lastModified;}catch(e){}}));
archives.sort((a,b)=>(b.mtime||0)-(a.mtime||0));
// 2) parallèle : liste TOUS les fichiers du dossier (pas de lecture cnf au scan)
const out=await Promise.all(dirs.map(async ([name,handle])=>{
const base=name.slice(0,-(DOMAIN.length+1));
const c={name:base,dir:name,handle,files:[],date:null,info:null}; // info: chargé au clic
const jobs=[];
for await (const [fn,fh] of handle.entries()){
if(fh.kind!=='file') continue;
const ext=(fn.split('.').pop()||'').toLowerCase();
const f={name:fn,ext,handle:fh,size:0,mtime:null};
c.files.push(f);
jobs.push((async()=>{
try{
const file=await fh.getFile(); // métadonnées seules (pas de lecture cnf)
f.size=file.size; f.mtime=file.lastModified;
if(!c.date||file.lastModified>c.date) c.date=file.lastModified;
}catch(e){}
})());
}
await Promise.all(jobs);
c.files.sort((a,b)=>a.name.localeCompare(b.name));
return c.files.length ? c : null;
}));
const certs=out.filter(Boolean).sort((a,b)=>(b.date||0)-(a.date||0));
return {certs, archives};
}
// télécharge un fichier (texte OU binaire .der/.p7b) directement depuis le disque
async function dlFile(f){
const file=await f.handle.getFile();
const a=document.createElement('a');a.href=URL.createObjectURL(file);a.download=f.name;a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
async function zipAll(c){
const zip=new JSZip();const fold=zip.folder(c.dir);
for(const f of c.files) fold.file(f.name, await f.handle.getFile());
const blob=await zip.generateAsync({type:'blob'});
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${c.dir}.zip`;a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
}
function fmtSize(n){return n<1024?n+' o':n<1048576?(n/1024).toFixed(1)+' Ko':(n/1048576).toFixed(1)+' Mo';}
/* ----- archivage (.zip ; tar non dispo en navigateur) ----- */
let archCert=null;
function openArchiveModal(c){
archCert=c;
document.getElementById('aTitle').textContent=c.dir;
archChoice();
document.getElementById('amodal').classList.add('show');
}
// vue 1 : choix
function archChoice(){
document.getElementById('aSub').textContent="Que veux-tu faire ?";
document.getElementById('aBody').innerHTML=`
<div class="achoice">
<button class="btn" id="acCreate"> Créer une archive</button>
<button class="btn sec" id="acSearch">🔍 Chercher des archives</button>
</div>`;
document.getElementById('acCreate').onclick=archCreateView;
document.getElementById('acSearch').onclick=archSearchView;
}
// vue 2 : créer
function archCreateView(){
document.getElementById('aSub').textContent="Créer une archive .zip du dossier";
document.getElementById('aBody').innerHTML=`
<button class="btn ghost sm aBack">← Retour</button>
<label style="margin-top:14px">Date de l'archive</label>
<input type="date" id="aDate">
<p class="hint">Enregistrée dans <code>archives/&lt;dossier&gt;_&lt;date&gt;.zip</code> (ou téléchargée si aucun dossier lié). Tar non supporté par le navigateur → zip.</p>
<div class="banner" id="aMsg"></div>
<button class="btn" id="aGo" style="margin-top:8px">🗄 Créer l'archive</button>`;
document.getElementById('aDate').value=new Date().toISOString().slice(0,10);
document.querySelector('.aBack').onclick=archChoice;
document.getElementById('aGo').onclick=doArchive;
}
// vue 3 : chercher
async function archSearchView(){
document.getElementById('aSub').textContent="Archives existantes pour ce dossier";
const b=document.getElementById('aBody');
b.innerHTML=`<button class="btn ghost sm aBack">← Retour</button>
<div id="aList"><div class="empty"><span class="spin"></span>Recherche…</div></div>`;
document.querySelector('.aBack').onclick=archChoice;
const list=await listArchives(archCert.dir);
const L=document.getElementById('aList');
if(!list.length){L.innerHTML=`<div class="empty">Aucune archive trouvée pour « ${archCert.dir} ».</div>`;return;}
L.innerHTML='';
list.forEach(a=>{
const row=document.createElement('div');row.className='frow';
row.innerHTML=`<span class="fico">📦</span>
<span class="fname">${a.name}<br><small style="color:var(--muted)">${a.mtime?new Date(a.mtime).toLocaleString('fr-FR'):''} · ${fmtSize(a.size)}</small></span>
<button class="btn sec sm">⬇ Récupérer</button>`;
row.querySelector('button').onclick=()=>dlFile(a);
L.appendChild(row);
});
}
// liste les archives (archives/) filtrées par nom de dossier
async function listArchives(dir){
if(API.on){
const j=await apiList();
const d=(dir||'').toLowerCase();
return (j.archives||[]).filter(a=>!d||a.name.toLowerCase().includes(d))
.map(a=>({name:a.name,size:a.size,mtime:a.mtime,
handle:{ getFile: async()=>{ const resp=await fetch(`${WORKDIR}/archives/${encodeURIComponent(a.name)}`,{cache:'no-store'});
return new File([await resp.blob()],a.name,{lastModified:a.mtime||Date.now()}); } }}))
.sort((a,b)=>(b.mtime||0)-(a.mtime||0));
}
if(!rootHandle) return [];
const out=[];
async function grab(handle){
for await (const [n,h] of handle.entries()){
if(h.kind==='file' && isArchive(n) && (!dir||n.toLowerCase().includes(dir.toLowerCase())))
out.push({name:n,handle:h,size:0,mtime:null});
}
}
await grab(rootHandle);
try{ await grab(await rootHandle.getDirectoryHandle('archives')); }catch(e){}
await Promise.all(out.map(async a=>{try{const f=await a.handle.getFile();a.size=f.size;a.mtime=f.lastModified;}catch(e){}}));
return out.sort((a,b)=>(b.mtime||0)-(a.mtime||0));
}
async function doArchive(){
const c=archCert; if(!c) return;
const date=document.getElementById('aDate').value||new Date().toISOString().slice(0,10);
const m=document.getElementById('aMsg');m.className='banner';m.innerHTML='<span class="spin"></span>Création…';
try{
const zip=new JSZip();const fold=zip.folder(c.dir);
for(const f of c.files) fold.file(f.name, await f.handle.getFile());
const blob=await zip.generateAsync({type:'blob'});
const fname=`${c.dir}_${date}.zip`;
if(API.on){ // serveur → archives/ sur disque
await apiWriteBin(`archives/${fname}`,blob);
m.className='banner ok';m.textContent=`✅ Archive créée sur le disque : ${WORKDIR}/archives/${fname}`;
}else if(rootHandle){ // écrit dans <dossier>/archives/
const arch=await rootHandle.getDirectoryHandle('archives',{create:true});
const fh=await arch.getFileHandle(fname,{create:true});
const w=await fh.createWritable();await w.write(blob);await w.close();
m.className='banner ok';m.textContent=`✅ Archive créée : archives/${fname}`;
}else{ // fallback téléchargement
const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=fname;a.click();
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
m.className='banner ok';m.textContent=`✅ Archive téléchargée : ${fname}`;
}
}catch(e){m.className='banner err';m.textContent='Erreur: '+e.message;}
}
/* ----- modal fichiers ----- */
function openCertModal(c){
document.getElementById('mTitle').textContent=c.dir;
renderFilesView(c);
document.getElementById('modal').classList.add('show');
}
function renderFilesView(c){
document.getElementById('mSub').textContent=`${c.files.length} fichier(s)`;
const body=document.getElementById('mBody');body.innerHTML='';
c.files.forEach(f=>{
const row=document.createElement('div');row.className='frow';
row.innerHTML=`<span class="fico">${extIcon(f.ext)}</span>
<span class="fname">${f.name}</span>
<span class="fsz">${fmtSize(f.size)}</span>
<button class="btn sec sm dlB" title="Télécharger">⬇</button>
<button class="btn sec sm del delB" title="Supprimer ce fichier">${SVG_TRASH}</button>`;
row.querySelector('.dlB').onclick=()=>dlFile(f);
row.querySelector('.delB').onclick=()=>deleteFile(c,f);
body.appendChild(row);
});
document.getElementById('mFoot').innerHTML=
`<button class="btn" id="mZip">📦 .zip</button>
<button class="btn sec" id="mFc">🔗 Construire fullchain.pem</button>`;
document.getElementById('mZip').onclick=()=>zipAll(c);
document.getElementById('mFc').onclick=()=>showChainView(c);
}
/* Supprime un fichier du dossier du certificat (un par un).
Mode serveur : PAS de route dédiée requise — on bufferise les autres fichiers,
on supprime le dossier (route `delete` existante), puis on réécrit les gardés
(route `write_bin` existante). Fonctionne sans redémarrer cert_server.py. */
async function deleteFile(c,f){
if(!confirm(`Supprimer le fichier « ${f.name} » ?\nCette action est irréversible.`)) return;
try{
if(c.handle){ // disque / OPFS : suppression directe
await c.handle.removeEntry(f.name);
}else if(API.on){ // serveur : reconstruit le dossier sans le fichier
const keep = c.files.filter(x => x !== f);
const bufs = [];
for(const k of keep) bufs.push([k.name, await k.handle.getFile()]); // bufferise AVANT toute suppression
await apiDeleteDir(c.dir, false); // supprime le dossier (garde les archives)
for(const [name, blob] of bufs) await apiWriteBin(`${c.dir}/${name}`, blob);
}else{
alert('Suppression impossible sans dossier de travail.'); return;
}
c.files = c.files.filter(x => x !== f);
renderFilesView(c);
renderList(); // rafraîchit la liste + l'index
}catch(e){ alert('Suppression échouée : '+e.message); }
}
/* ----- fullchain ----- */
function dnStr(attrs){return attrs.map(a=>`${a.shortName||a.type}=${a.value}`).join(',');}
function certCN(cert){const f=cert.subject.getField('CN');return f?f.value:'(sans CN)';}
async function collectCerts(c){
const out=[];
for(const f of c.files){
try{
const file=await f.handle.getFile();
const txt=await file.text();
if(txt.includes('BEGIN CERTIFICATE')){
(txt.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)||[])
.forEach(b=>{try{out.push({cert:forge.pki.certificateFromPem(b),from:f.name});}catch(e){}});
}else if(['der','cer','crt'].includes(f.ext)){
try{const u8=new Uint8Array(await file.arrayBuffer());let bin='';for(let i=0;i<u8.length;i++)bin+=String.fromCharCode(u8[i]);
out.push({cert:forge.pki.certificateFromAsn1(forge.asn1.fromDer(bin)),from:f.name});}catch(e){}
}
}catch(e){}
}
const seen=new Set(),res=[]; // dedup
for(const x of out){const p=forge.pki.certificateToPem(x.cert);if(!seen.has(p)){seen.add(p);res.push(x);}}
return res;
}
// ordonne leaf → intermédiaire(s) → root via liens issuer/subject
function buildChain(items){
const certs=items.map(x=>x.cert);
const bySubj={}; certs.forEach(c=>{bySubj[dnStr(c.subject.attributes)]=c;});
const issuers=new Set(certs.map(c=>dnStr(c.issuer.attributes)));
const leaf=certs.find(c=>!issuers.has(dnStr(c.subject.attributes)))||certs[0];
const chain=[],used=new Set(); let cur=leaf;
while(cur && !used.has(dnStr(cur.subject.attributes))){
used.add(dnStr(cur.subject.attributes));
const self=dnStr(cur.subject.attributes)===dnStr(cur.issuer.attributes);
chain.push({cert:cur,self});
if(self) break;
cur=bySubj[dnStr(cur.issuer.attributes)];
}
certs.forEach(c=>{const s=dnStr(c.subject.attributes);if(!used.has(s)){used.add(s);chain.push({cert:c,self:s===dnStr(c.issuer.attributes)});}});
return chain.map((x,i)=>({cert:x.cert, role:x.self?'root':(i===0?'leaf':'intermediate')}));
}
async function showChainView(c){
document.getElementById('mSub').textContent='Assemblage fullchain.pem';
const body=document.getElementById('mBody');
body.innerHTML=`<button class="btn ghost sm" id="fcBack">← Retour</button>
<div id="fcArea"><div class="empty"><span class="spin"></span>Analyse des certificats…</div></div>`;
document.getElementById('mFoot').innerHTML='';
document.getElementById('fcBack').onclick=()=>renderFilesView(c);
const found=await collectCerts(c);
const area=document.getElementById('fcArea');
if(!found.length){area.innerHTML=`<div class="empty">Aucun certificat (PEM/DER) trouvé dans le dossier.<br>Ajoute le cert signé + les intermédiaires de la CA.</div>`;return;}
const chain=buildChain(found);
const hasRoot=chain.some(x=>x.role==='root');
area.innerHTML=`
<p class="hint">Ordre détecté (${chain.length} cert) :</p>
${chain.map((x,i)=>`<div class="frow"><span class="fico">${x.role==='leaf'?'🌿':x.role==='root'?'🏛️':'🔗'}</span>
<span class="fname">${i+1}. ${certCN(x.cert)}<br><small style="color:var(--muted)">${ROLE_FR[x.role]}</small></span></div>`).join('')}
<label style="display:flex;align-items:center;gap:8px;margin:14px 0;font-weight:500">
<input type="checkbox" id="fcRoot" ${hasRoot?'':'disabled'}> Inclure la racine (root) ${hasRoot?'':'(non trouvée)'}
</label>
<div class="banner" id="fcMsg"></div>
<button class="btn" id="fcGo">🔗 Créer fullchain.pem</button>`;
document.getElementById('fcGo').onclick=()=>makeFullchain(c,chain);
}
const ROLE_FR={leaf:'Certificat serveur (feuille)',intermediate:'Intermédiaire CA',root:'Autorité racine'};
async function makeFullchain(c,chain){
const inclRoot=document.getElementById('fcRoot').checked;
const items=chain.filter(x=>inclRoot||x.role!=='root');
const pem=items.map(x=>forge.pki.certificateToPem(x.cert).trim()).join('\n')+'\n';
const m=document.getElementById('fcMsg');
try{
if(API.on){
await apiWriteText(c.dir,{'fullchain.pem':pem});
m.className='banner ok';m.textContent=`${WORKDIR}/${c.dir}/fullchain.pem écrit sur le disque (${items.length} cert).`;
}else if(rootHandle){
const fh=await c.handle.getFileHandle('fullchain.pem',{create:true});
const w=await fh.createWritable();await w.write(pem);await w.close();
m.className='banner ok';m.textContent=`${c.dir}/fullchain.pem écrit (${items.length} cert).`;
}else{
m.className='banner ok';m.textContent=`✅ fullchain.pem généré (${items.length} cert).`;
}
download('fullchain.pem',pem); // + téléchargement
}catch(e){m.className='banner err';m.textContent='Erreur: '+e.message;}
}
function extIcon(e){return({key:'🔑',csr:'📝',cnf:'⚙️',pem:'📜',crt:'📜',cer:'📜',der:'🧩',p7b:'📦',p12:'🔐',pfx:'🔐',bundle:'📚'})[e]||'📄';}
/* icône corbeille SVG (couleur = currentColor → rouge via .del) */
const SVG_TRASH='<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6"/></svg>';
/* Ajoute manuellement des fichiers (depuis le PC) dans le dossier du certificat. */
async function addFilesToCert(c){
if(!API.on && !c.handle){
wsBanner('warn','⚠ Ajout impossible sans dossier de travail (lance « python cert_server.py » ou choisis un dossier disque).');
return;
}
const inp=document.createElement('input'); inp.type='file'; inp.multiple=true;
inp.onchange=async()=>{
const files=[...inp.files]; if(!files.length) return;
try{
for(const f of files){
if(API.on){ await apiWriteBin(`${c.dir}/${f.name}`, f); }
else {
const fh=await c.handle.getFileHandle(f.name,{create:true});
const w=await fh.createWritable(); await w.write(f); await w.close();
}
}
wsBanner('ok',`${files.length} fichier(s) ajouté(s) à ${c.dir}.`);
renderList();
}catch(e){ wsBanner('err','❌ Ajout échoué : '+e.message); }
};
inp.click();
}
/* charge DNS/IP à la demande (lit le .cnf seulement au clic, puis met en cache) */
async function loadInfo(c){
if(c.info) return c.info;
const cnf=c.files.find(f=>f.ext==='cnf');
if(!cnf){c.info={dns:[],ip:[],none:true};return c.info;}
try{c.info=parseCnf(await (await cnf.handle.getFile()).text());}
catch(e){c.info={dns:[],ip:[],err:e.message};}
return c.info;
}
document.getElementById('mClose').onclick=()=>document.getElementById('modal').classList.remove('show');
document.getElementById('modal').onclick=e=>{if(e.target.id==='modal')document.getElementById('modal').classList.remove('show');};
async function renderList(){
const L=document.getElementById('list');
if(!API.on && !rootHandle){
L.innerHTML=`<div class="empty">Aucun dossier de travail.<br><br>Lance « <b>python cert_server.py</b> » pour écrire/scanner sur le disque, ou clique « <b>📂 Dossier disque…</b> » en haut.${HAS_FS?'':'<br><span style="color:var(--warn)">(navigateur non compatible — utilise Chrome/Edge)</span>'}</div>`;
return;
}
L.innerHTML=`<div class="empty"><span class="spin"></span>Scan du dossier…</div>`;
let res={certs:[],archives:[]};
try{res=API.on ? await scanServer() : await scanDisk();}catch(e){L.innerHTML=`<div class="empty">Erreur scan: ${e.message}</div>`;return;}
const q=document.getElementById('q').value.trim().toLowerCase();
const match=n=>!q||n.toLowerCase().includes(q);
const certs=res.certs.filter(c=>match(c.dir)||c.files.some(f=>match(f.name)));
L.innerHTML='';
if(!certs.length){
const e=document.createElement('div');e.className='empty';
e.textContent=res.certs.length?'Aucun certificat ne correspond.':`Aucun certificat (*.${DOMAIN}) trouvé dans le dossier.`;
L.appendChild(e);
return;
}
certs.forEach(c=>{
const el=document.createElement('div');el.className='cert';
el.innerHTML=`<div class="body">
<h3>${c.dir} <span class="badge-slot"></span></h3>
<div class="meta">${c.date?'Modifié le '+new Date(c.date).toLocaleString('fr-FR'):''} · ${c.files.length} fichier(s)</div>
<div class="sans"></div>
</div>
<div class="cbtns">
<button class="btn sec ico infoB" title="Plus d'informations (DNS / IP)">i</button>
<button class="btn sec ico addB" title="Ajouter des fichiers (depuis le PC)"></button>
<button class="btn sec ico showB" title="Télécharger">⬇</button>
<button class="btn sec ico archB" title="Archiver">🗄</button>
<button class="btn sec ico del delB" title="Supprimer le dossier">${SVG_TRASH}</button>
</div>`;
const sans=el.querySelector('.sans'), infoB=el.querySelector('.infoB');
infoB.onclick=async()=>{
if(sans.dataset.open==='1'){sans.innerHTML='';sans.dataset.open='0';infoB.classList.remove('on');return;}
infoB.disabled=true;
const info=await loadInfo(c);
sans.innerHTML = info.none ? '<span class="tag">Aucun .cnf (pas de SAN)</span>'
: info.err ? `<span class="tag">Erreur: ${info.err}</span>`
: (info.dns.map(d=>`<span class="tag">DNS ${d}</span>`).join('')
+info.ip.map(p=>`<span class="tag">IP ${p}</span>`).join('')
|| '<span class="tag">Aucun SAN</span>');
sans.dataset.open='1';infoB.disabled=false;infoB.classList.add('on');
};
el.querySelector('.body').onclick=()=>openCertModal(c); // clic sur la carte = ouvrir
el.querySelector('.addB').onclick=()=>addFilesToCert(c);
el.querySelector('.showB').onclick=()=>openCertModal(c);
el.querySelector('.archB').onclick=()=>openArchiveModal(c);
el.querySelector('.delB').onclick=()=>deleteCert(c);
L.appendChild(el);
// badge expiration : chargé en arrière-plan (n'attend pas / ne bloque pas le scan)
loadExpiry(c).then(e=>{const s=el.querySelector('.badge-slot');if(s)s.innerHTML=expBadge(e);});
});
// publie l'index complet (DNS/IP) pour la CMDB — en arrière-plan
publishCertIndex(res.certs);
}
/* lit un fichier cert (PEM ou DER) et renvoie {notAfter, days} — lazy, mis en cache */
async function parseCertFile(f){
const file=await f.handle.getFile();
const txt=await file.text();
if(txt.includes('BEGIN CERTIFICATE')) return forge.pki.certificateFromPem(txt);
const u8=new Uint8Array(await file.arrayBuffer()); // DER binaire → binary string
let bin=''; for(let i=0;i<u8.length;i++) bin+=String.fromCharCode(u8[i]);
return forge.pki.certificateFromAsn1(forge.asn1.fromDer(bin));
}
async function loadExpiry(c){
if(c.expiry!==undefined) return c.expiry;
const cands=c.files.filter(f=>['crt','cer','pem','der'].includes(f.ext));
for(const f of cands){
try{
const cert=await parseCertFile(f);
const na=cert.validity.notAfter;
c.expiry={notAfter:na, days:Math.floor((na.getTime()-Date.now())/86400000)};
return c.expiry;
}catch(e){}
}
c.expiry=null; return null; // pas de cert signé (ex: CSR seul)
}
function expBadge(e){
if(!e) return '';
const d=new Date(e.notAfter).toLocaleDateString('fr-FR');
if(e.days<0) return `<span class="badge exp" title="Expiré le ${d}">⛔ expiré</span>`;
if(e.days<=30) return `<span class="badge soon" title="Expire le ${d}">⚠ expire dans ${e.days}j</span>`;
return `<span class="badge ok" title="Valide jusqu'au ${d}">✔ valide ${e.days}j</span>`;
}
async function deleteCert(c){
if(!confirm(`⚠ SUPPRESSION DÉFINITIVE\n\nSupprimer le dossier « ${c.dir} » et TOUS ses fichiers (${c.files.length}) du disque ?\n\nCette action est IRRÉVERSIBLE.`)) return;
// demande pour les archives liées
let delArch=false;
const arch=await listArchives(c.dir);
if(arch.length) delArch=confirm(`${arch.length} archive(s) liée(s) à « ${c.dir} » trouvée(s) :\n${arch.map(a=>' • '+a.name).join('\n')}\n\nLes supprimer aussi ? (Annuler = garder les archives)`);
try{
if(API.on){
await apiDeleteDir(c.dir,delArch);
}else{
await rootHandle.removeEntry(c.dir,{recursive:true});
if(delArch) await deleteArchives(c.dir);
}
renderList();
}catch(e){ alert('Erreur suppression: '+e.message); }
}
// supprime les fichiers archive (archives/) contenant <dir> dans le nom
async function deleteArchives(dir){
if(API.on){ await apiDeleteArchives(dir); return; }
const d=dir.toLowerCase();
for await (const [n,h] of rootHandle.entries())
if(h.kind==='file' && isArchive(n) && n.toLowerCase().includes(d)) await rootHandle.removeEntry(n);
try{
const ah=await rootHandle.getDirectoryHandle('archives');
for await (const [n,h] of ah.entries())
if(h.kind==='file' && isArchive(n) && n.toLowerCase().includes(d)) await ah.removeEntry(n);
}catch(e){}
}
/* handlers modal archive */
document.getElementById('aClose').onclick=()=>document.getElementById('amodal').classList.remove('show');
document.getElementById('amodal').onclick=e=>{if(e.target.id==='amodal')document.getElementById('amodal').classList.remove('show');};
resetGenArea();
</script>
</body>
</html>