Dashboard_Admin_Sys/password-generator/password_generator.html
Guillaume DENIS 7bdcd9193a feat(password-generator): affiche la version de la base et arrondit les mails
- sidebar: réaffiche l'indicateur de version de la base partagée
  (● Base partagée (vN)), aligné sur cmdb/switch/cert
- mails HTML: banner plus arrondi (18px), bloc de code arrondi porté par le
  <pre> et padding réduit
- bump du sous-module cmdb

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:39:50 +02:00

1244 lines
62 KiB
HTML
Raw Permalink 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 mots de passe — Admin</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
<style>
:root{
--page:#0c0d10;
--surface:#15171c;
--surface-2:#1d2026;
--surface-3:#262a32;
--ink:#f2f3f5;
--ink-2:#b8bcc4;
--muted:#7c8189;
--border:rgba(255,255,255,0.07);
--border-strong:rgba(255,255,255,0.14);
--grid:#23262d;
--accent:#5b9cf5;
--accent-soft:rgba(91,156,245,0.14);
--danger:#e66767;
--ok:#4ecfa5;
--radius:12px;
--shadow:0 10px 30px rgba(0,0,0,.35);
}
*{box-sizing:border-box;margin:0;padding:0}
html{scrollbar-color:#33363e var(--page)}
body{
background:var(--page);color:var(--ink);
font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
font-size:14px;display:flex;min-height:100vh;
-webkit-font-smoothing:antialiased;
}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#33363e;border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- sidebar ---------- */
aside{
width:232px;flex-shrink:0;background:var(--surface);
border-right:1px solid var(--border);padding:22px 14px;
display:flex;flex-direction:column;gap:3px;position:sticky;top:0;height:100vh;z-index:40;
}
.logo{display:flex;align-items:center;gap:10px;padding:0 10px 22px}
.logo-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;
}
.logo b{font-size:15px;letter-spacing:.4px}
.logo small{display:block;font-size:10px;color:var(--muted);font-weight:400;letter-spacing:1px;text-transform:uppercase}
.nav-btn{
display:flex;align-items:center;gap:11px;width:100%;
background:none;border:none;color:var(--ink-2);cursor:pointer;
padding:10px 12px;border-radius:9px;font-size:13.5px;text-align:left;
transition:background .12s,color .12s;font-family:inherit;
}
.nav-btn svg{width:17px;height:17px;flex-shrink:0;opacity:.8}
.nav-btn:hover{background:var(--surface-2);color:var(--ink)}
.nav-btn.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
.nav-btn.active svg{opacity:1}
.nav-sep{height:1px;background:var(--border);margin:8px 4px 10px}
.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(--ink-2);transition:background .12s,color .12s}
.psw a:hover{background:var(--surface-2)}
.psw a.active{background:var(--accent-soft)}
.psw a.active b{color:var(--accent)}
.psw-ico{width:30px;height:30px;border-radius:8px;background:var(--surface-2);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(--ink);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}
.sidebar-footer{margin-top:auto;display:flex;flex-direction:column;gap:7px;padding-top:14px;border-top:1px solid var(--border)}
.db-status{font-size:11px;color:var(--muted);padding:4px 8px;word-break:break-word;line-height:1.5}
/* ---------- main ---------- */
main{flex:1;padding:30px 36px;max-width:1100px;min-width:0}
.page{display:none;animation:fadein .18s ease}
.page.active{display:block}
@keyframes fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.page-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:22px;gap:16px;flex-wrap:wrap}
.page-head h2{font-size:21px;font-weight:700;letter-spacing:-.2px}
.page-head p{color:var(--muted);font-size:13px;margin-top:5px}
/* ---------- buttons / inputs (repris de la CMDB) ---------- */
.btn{
background:var(--surface-2);color:var(--ink);border:1px solid var(--border-strong);
border-radius:9px;padding:8px 15px;font-size:13px;cursor:pointer;font-family:inherit;
transition:background .12s,border-color .12s,transform .08s;
display:inline-flex;align-items:center;gap:7px;
}
.btn:hover{background:var(--surface-3);border-color:var(--muted)}
.btn:active{transform:scale(.98)}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;box-shadow:0 2px 12px rgba(91,156,245,.25)}
.btn.primary:hover{background:#4a8ce8}
.btn.danger{color:var(--danger)}
.btn.small{padding:5px 10px;font-size:12px;border-radius:7px}
.btn.ghost{background:none;border:none;color:var(--muted);box-shadow:none}
.btn.ghost:hover{color:var(--ink);background:var(--surface-3)}
.btn:disabled{opacity:.45;cursor:not-allowed;transform:none}
input,select{
background:var(--surface-2);border:1px solid var(--border-strong);color:var(--ink);
border-radius:9px;padding:8px 11px;font-size:13px;font-family:inherit;height:36px;
transition:border-color .12s;
}
input::placeholder{color:var(--muted)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
input[type=number]{width:100%}
input:disabled{opacity:.45;cursor:not-allowed}
input[type=checkbox]{
-webkit-appearance:none;appearance:none;width:18px;height:18px;min-width:18px;
padding:0;border-radius:5px;border:1.5px solid var(--border-strong);background:var(--surface-2);
cursor:pointer;position:relative;transition:background .12s,border-color .12s;flex-shrink:0;
}
input[type=checkbox]:checked{background:var(--accent);border-color:var(--accent)}
input[type=checkbox]:checked::after{
content:'';position:absolute;left:5px;top:1.5px;width:4px;height:9px;
border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg);
}
input[type=range]{-webkit-appearance:none;appearance:none;height:6px;padding:0;border:none;border-radius:3px;background:var(--surface-3);cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);border:2px solid #fff2}
textarea{
background:var(--surface-2);border:1px solid var(--border-strong);color:var(--ink);
border-radius:9px;padding:9px 11px;font-size:13px;font-family:inherit;width:100%;
min-height:66px;resize:vertical;line-height:1.5;
}
textarea::placeholder{color:var(--muted)}
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:20px}
.card h3{font-size:13px;font-weight:600;margin-bottom:14px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px}
.field{margin-bottom:15px}
.field label{display:block;font-size:11.5px;color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.field input,.field select{width:100%}
.field-row{display:flex;gap:12px}
.field-row .field{flex:1}
.hint{font-size:11px;color:var(--muted);margin-top:5px;line-height:1.5}
.check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-2);cursor:pointer;user-select:none}
.toolbar{display:flex;gap:9px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
table{width:100%;border-collapse:collapse}
th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--grid)}
td{padding:11px 12px;border-bottom:1px solid var(--grid);color:var(--ink-2);vertical-align:middle;font-size:13px}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(255,255,255,0.025)}
td .name{color:var(--ink);font-weight:600}
.empty{color:var(--muted);text-align:center;padding:30px 0;font-size:13px}
/* ---------- onglets de mode ---------- */
.mode-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:18px}
.mode-tab{
background:var(--surface-2);border:1px solid var(--border-strong);color:var(--ink-2);
border-radius:8px;padding:8px 15px;font-size:13px;cursor:pointer;font-family:inherit;
transition:background .12s,color .12s,border-color .12s;display:inline-flex;align-items:center;gap:8px;
}
.mode-tab:hover{background:var(--surface-3);color:var(--ink)}
.mode-tab.on{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
/* ---------- résultat ---------- */
.result-box{
display:flex;align-items:center;gap:10px;background:var(--surface-2);
border:1px solid var(--border-strong);border-radius:11px;padding:14px 16px;margin-bottom:12px;
}
.result-pw{
flex:1;min-width:0;font-family:ui-monospace,"Cascadia Mono",Consolas,monospace;
font-size:20px;letter-spacing:.5px;word-break:break-all;line-height:1.35;color:var(--ink);
}
.result-pw .pfx{color:var(--accent)}
.result-pw .d{color:#e8b34b}
.result-pw .s{color:#e884ae}
.meter{height:6px;border-radius:3px;background:var(--surface-3);overflow:hidden;margin:4px 0 6px}
.meter i{display:block;height:100%;border-radius:3px;transition:width .2s,background .2s}
.entropy{font-size:11.5px;color:var(--muted)}
/* ---------- recherche AD ---------- */
.ac-wrap{position:relative}
.ac-list{
position:absolute;left:0;right:0;top:100%;margin-top:4px;z-index:20;
background:var(--surface-2);border:1px solid var(--border-strong);border-radius:9px;
box-shadow:var(--shadow);overflow:hidden;max-height:240px;overflow-y:auto;
}
.ac-item{padding:8px 12px;font-size:13px;color:var(--ink-2);cursor:pointer;display:flex;align-items:center;gap:7px}
.ac-item:hover{background:var(--accent-soft);color:var(--ink)}
.ac-item b{color:var(--ink);font-weight:600}
.ac-sub{margin-left:auto;color:var(--muted);font-size:11.5px;padding-left:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.user-card{
display:flex;align-items:center;gap:12px;background:var(--surface-2);
border:1px solid var(--border);border-radius:10px;padding:11px 14px;margin-top:10px;
}
.user-card .uc-avatar{
width:38px;height:38px;border-radius:50%;background:var(--accent-soft);color:var(--accent);
display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;flex-shrink:0;
}
.user-card .uc-info{flex:1;min-width:0}
.user-card .uc-name{font-weight:600;color:var(--ink);font-size:13.5px}
.user-card .uc-sub{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.badge{display:inline-block;padding:2.5px 10px;border-radius:6px;font-size:11px;font-weight:600;letter-spacing:.3px}
.badge.ok{background:rgba(78,207,165,.15);color:var(--ok)}
.badge.warn{background:rgba(230,103,103,.15);color:var(--danger)}
.preview{
background:var(--surface-2);border:1px solid var(--border);border-radius:10px;
padding:13px 15px;font-size:13px;color:var(--ink-2);white-space:pre-wrap;line-height:1.6;
}
.preview .pv-subject{font-weight:600;color:var(--ink);padding-bottom:9px;margin-bottom:9px;border-bottom:1px solid var(--grid)}
/* ---------- modal / toasts ---------- */
.overlay{
display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);
z-index:50;align-items:flex-start;justify-content:center;padding:6vh 20px;backdrop-filter:blur(3px);
}
.overlay.open{display:flex}
.modal{
background:var(--surface);border:1px solid var(--border-strong);border-radius:16px;
width:100%;max-width:580px;max-height:86vh;overflow-y:auto;padding:26px;
box-shadow:var(--shadow);animation:pop .16s ease;
}
@keyframes pop{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}
.modal h3{font-size:17px;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:22px}
#toastWrap{position:fixed;bottom:20px;right:20px;z-index:200;display:flex;flex-direction:column;gap:8px;max-width:380px}
.toast{
background:var(--surface);border:1px solid var(--border-strong);border-left:4px solid var(--accent);
border-radius:10px;padding:12px 15px;font-size:13px;color:var(--ink-2);box-shadow:var(--shadow);
animation:fadein .15s ease;line-height:1.45;
}
.toast.warn{border-left-color:var(--danger);color:var(--ink)}
</style>
</head>
<body>
<aside>
<div class="logo">
<div class="logo-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 href="../cert-generator/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="../switch-config/switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
<a class="active" href="password_generator.html"><div class="psw-ico">🔑</div><div class="psw-txt"><b>Mots de passe</b><small>Générateur &amp; envoi</small></div></a>
</div>
<div class="sidebar-footer">
<div class="psw">
<a href="../cmdb/preferences.html#pwgen"><div class="psw-ico"></div><div class="psw-txt"><b>Préférences</b><small>Réglages du générateur</small></div></a>
</div>
<div class="db-status" id="dbStatus">chargement…</div>
</div>
</aside>
<main>
<!-- ==================== GENERATEUR ==================== -->
<div class="page active" id="page-generator">
<div class="page-head">
<div><h2>Générateur de mots de passe</h2><p>Mots de passe aléatoires, passphrases et mots de passe temporaires</p></div>
</div>
<div class="mode-tabs">
<button class="mode-tab on" data-mode="password" onclick="setMode('password')">🔑 Mot de passe</button>
<button class="mode-tab" data-mode="passphrase" onclick="setMode('passphrase')">📝 Passphrase</button>
<button class="mode-tab" data-mode="temp" onclick="setMode('temp')">👤 Utilisateur temporaire</button>
</div>
<!-- template de génération (haut) : mot de passe + temporaire -->
<div class="card" id="tplBar">
<div class="field-row" style="align-items:flex-end;margin:0">
<div class="field" style="margin:0;flex:1;max-width:360px">
<label>Template de génération</label>
<select id="genPreset" onchange="applyPreset(this.value)"></select>
</div>
<a class="btn small" href="../cmdb/preferences.html#pwgen" style="text-decoration:none" title="Gérer les templates dans les Préférences">⚙ Gérer</a>
</div>
<div class="hint" style="margin-top:8px">Préréglage charset + longueur + préfixe. « (Personnalisé) » = réglages manuels ci-dessous.</div>
</div>
<!-- résultat commun -->
<div class="card">
<div class="result-box">
<div class="result-pw" id="resultPw"></div>
<button class="btn small" onclick="regen()" title="Régénérer"></button>
<button class="btn primary small" onclick="copyResult()" title="Copier">⧉ Copier</button>
</div>
<div class="meter"><i id="meterBar" style="width:0"></i></div>
<div class="entropy" id="entropyTxt"></div>
</div>
<!-- options charset : mot de passe + temporaire (moteur commun) -->
<div class="card" id="opts-charset">
<h3 id="charsetTitle">Options — mot de passe aléatoire</h3>
<div class="field-row" style="max-width:560px">
<div class="field">
<label>Préfixe (optionnel)</label>
<input id="genPrefix" placeholder="(aucun)" onchange="onCharsetOpt()">
</div>
<div class="field" style="flex:1.4">
<label>Caractères aléatoires : <span id="genLenVal"></span></label>
<div style="display:flex;gap:14px;align-items:center">
<input type="range" id="genLen" min="1" max="64" style="flex:1" oninput="onCharsetLen(this.value)">
<input type="number" id="genLenNum" min="1" max="128" style="width:80px" oninput="onCharsetLen(this.value)">
</div>
</div>
</div>
<div style="display:flex;gap:22px;flex-wrap:wrap">
<label class="check"><input type="checkbox" id="genLower" onchange="onCharsetOpt()"> Minuscules (a-z)</label>
<label class="check"><input type="checkbox" id="genUpper" onchange="onCharsetOpt()"> Majuscules (A-Z)</label>
<label class="check"><input type="checkbox" id="genDigits" onchange="onCharsetOpt()"> Chiffres (0-9)</label>
<label class="check"><input type="checkbox" id="genSymbols" onchange="onCharsetOpt()"> Symboles (!#$%…)</label>
</div>
</div>
<!-- options : passphrase -->
<div class="card" id="opts-passphrase" style="display:none">
<h3>Options — passphrase</h3>
<div class="field-row" style="max-width:480px">
<div class="field">
<label>Nombre de mots : <span id="ppLenVal"></span></label>
<input type="range" id="ppWords" min="2" max="10" oninput="onPpOpt()">
</div>
<div class="field" style="max-width:110px">
<label>Séparateur</label>
<input id="ppSep" maxlength="3" onchange="onPpOpt()">
</div>
</div>
<div style="display:flex;gap:22px;flex-wrap:wrap;align-items:center">
<label class="check"><input type="checkbox" id="ppCap" onchange="onPpOpt()"> Majuscule en début de mot</label>
<label class="check"><input type="checkbox" id="ppDigitEach" onchange="onPpOpt()"> Ajouter un chiffre à chaque mot</label>
<label class="check" style="gap:10px">Caractère spécial dans
<input type="number" id="ppSymWords" min="0" max="10" style="width:64px" onchange="onPpOpt()"> mot(s) aléatoire(s)</label>
</div>
</div>
<!-- envoi mail : utilisateur temporaire -->
<div class="card" id="mail-temp" style="display:none">
<h3>Envoi par mail (Active Directory)</h3>
<div class="field ac-wrap">
<label>Destinataire (recherche AD ou adresse email)</label>
<input id="adQuery" placeholder="Nom, prénom, username… ou email@exemple.com" autocomplete="off"
oninput="onRecipientInput(this.value)"
onblur="setTimeout(()=>{const b=document.getElementById('adResults');if(b)b.style.display='none'},180)">
<div id="adResults" class="ac-list" style="display:none"></div>
<div id="adHint" class="hint"></div>
</div>
<div id="selectedUser"></div>
<div class="field" style="margin-top:15px">
<label>Template email</label>
<select id="tplSelect" onchange="onTemplateChange()"></select>
</div>
<div class="field" id="varInputsField" style="display:none">
<label>Variables du template <span style="text-transform:none;letter-spacing:0;font-weight:400;color:var(--muted)">— pré-remplies depuis lAD, modifiables</span></label>
<div id="varInputs"></div>
</div>
<div class="field">
<label>Aperçu</label>
<div class="preview" id="mailPreview"><span style="color:var(--muted)">Sélectionnez un utilisateur…</span></div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px">
<button class="btn" id="mailtoBtn" onclick="openMailApp()" disabled title="Ouvre votre client mail par défaut, message pré-rempli">📧 Ouvrir dans lapp mail</button>
<button class="btn primary" id="sendBtn" onclick="sendMail()" disabled>✉ Envoyer le mot de passe</button>
</div>
</div>
</div>
</main>
<div id="toastWrap"></div>
<div class="overlay" id="overlay"><div class="modal" id="modalContent"></div></div>
<script>
"use strict";
/* ============================================================
Générateur de mots de passe — single-file app.
- Génération crypto (crypto.getRandomValues, tirage uniforme)
- Passphrases sur liste de mots française embarquée
- Mode « utilisateur temporaire » : préfixe + envoi par mail via
l'AD (config lue dans la base CMDB partagée /db) et SMTP
(POST /mail/send du serveur du portail).
- Préférences stockées en localStorage ('pwgen_prefs').
============================================================ */
/* ---------------- préférences ---------------- */
const PREF_KEY = 'pwgen_prefs';
const DEF_TEMPLATE = {
id: 'default',
format: 'text',
label: 'Mot de passe temporaire',
subject: 'Votre mot de passe temporaire',
body: 'Bonjour {{ prenom }},\n\nVoici ton mot de passe temporaire : {{ tmp_password }}\n\nMerci de le changer dès ta première connexion.\n\nLe support IT',
};
/* Exemple de template HTML enrichi (banner, image, listes, bloc de code…). */
const EXAMPLE_HTML_TPL = {
id: 'example_html',
format: 'html',
label: 'Exemple enrichi (HTML)',
subject: 'Bienvenue {{ prenom }} — vos accès',
body: [
':::banner #5b9cf5',
'Bienvenue chez ACME, {{ prenom }} 👋',
':::',
'',
'![Logo ACME](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iNDgiPjxyZWN0IHdpZHRoPSIxODAiIGhlaWdodD0iNDgiIHJ4PSI4IiBmaWxsPSIjNWI5Y2Y1Ii8+PHRleHQgeD0iOTAiIHk9IjMxIiBmb250LWZhbWlseT0iQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyMCIgZmlsbD0iI2ZmZmZmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC13ZWlnaHQ9ImJvbGQiPkFDTUUgwrcgSVQ8L3RleHQ+PC9zdmc+)',
'',
'## Vos identifiants',
'',
'Bonjour **{{ displayName }}**, votre compte a bien été créé. Voici vos informations de connexion :',
'',
'- Nom dutilisateur : `{{ username }}`',
'- Adresse email : `{{ mail }}`',
'- Mot de passe temporaire : {{ tmp_password }}',
'',
'## Première connexion',
'',
'1. Rendez-vous sur [le portail](https://portail.exemple.com)',
'2. Connectez-vous avec les identifiants ci-dessus',
'3. Changez votre mot de passe immédiatement',
'',
'### Exemple en ligne de commande',
'',
'```',
'ssh {{ username }}@serveur.exemple.com',
'passwd # changez votre mot de passe',
'```',
'',
'## Accès au bureau à distance',
'',
'Le fichier de connexion RDP est joint à ce mail : ouvrez-le pour lancer directement la session.',
'',
'Voir aussi [la documentation daccès distant](https://portail.exemple.com/vpn).',
'',
'---',
'',
'Besoin daide ? Contactez le **support IT**.',
].join('\n'),
attachments: [{ name: 'connexion.rdp', url: '/password-generator/files/connexion.rdp' }],
};
/* Templates de génération : préréglages charset + longueur + préfixe */
const DEF_GEN_PRESET = {
id: 'simple',
label: 'Simple Password',
len: 6, prefix: '',
lower: true, upper: false, digits: true, symbols: false,
};
/* Un profil de génération pilote le moteur charset (partagé mot de passe + temporaire).
Chaque mode a son profil (défauts distincts : 16 car. vs 8 car.). Un template
applique ses réglages au profil du mode actif. */
function defProfile(o){ return { len:16, prefix:'', lower:true, upper:true, digits:true, symbols:true, preset:'', ...o }; }
const DEF_PREFS = {
profiles: {
password: defProfile({ len: 16 }),
temp: defProfile({ len: 8, symbols: false }), // temporaire : plus court, sans symboles
},
pp_words: 4, pp_sep: '-', pp_cap: false, pp_digit_each: false, pp_sym_words: 0,
smtp: { host:'', port:'', security:'starttls', user:'', pass:'', from:'', from_name:'' },
templates: [DEF_TEMPLATE],
gen_presets: [DEF_GEN_PRESET],
};
let PREFS = loadPrefs();
function loadPrefs(){
try{
const p = { ...JSON.parse(JSON.stringify(DEF_PREFS)), ...JSON.parse(localStorage.getItem(PREF_KEY) || '{}') };
p.smtp = { ...DEF_PREFS.smtp, ...(p.smtp || {}) };
if(!Array.isArray(p.templates) || !p.templates.length) p.templates = [DEF_TEMPLATE];
p.templates.forEach(t => { if(!t.format) t.format = 'text'; if(!Array.isArray(t.attachments)) t.attachments = []; }); // schéma
if(!Array.isArray(p.gen_presets)) p.gen_presets = [DEF_GEN_PRESET];
// migration depuis l'ancien schéma à plat (pw_*, tmp_*)
if(!p.profiles || typeof p.profiles !== 'object'){
p.profiles = {
password: defProfile({ len:p.pw_len, lower:p.pw_lower, upper:p.pw_upper, digits:p.pw_digits, symbols:p.pw_symbols, preset:p.pw_preset||'' }),
temp: defProfile({ len:p.tmp_len!=null?p.tmp_len:8, prefix:p.tmp_prefix||'', symbols:false }),
};
}
p.profiles.password = defProfile(p.profiles.password);
p.profiles.temp = defProfile(p.profiles.temp);
return p;
}catch(e){ return JSON.parse(JSON.stringify(DEF_PREFS)); }
}
function savePrefs(){
try{ localStorage.setItem(PREF_KEY, JSON.stringify(PREFS)); }catch(e){} // cache local
schedulePush(); // + base partagée
}
/* ---------------- aléa crypto ---------------- */
function randInt(max){ // entier uniforme dans [0, max) — rejection sampling
const lim = Math.floor(0x100000000 / max) * max;
const buf = new Uint32Array(1);
do{ crypto.getRandomValues(buf); }while(buf[0] >= lim);
return buf[0] % max;
}
function pick(str){ return str[randInt(str.length)]; }
function shuffle(arr){
for(let i = arr.length - 1; i > 0; i--){
const j = randInt(i + 1); [arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const SETS = {
lower: 'abcdefghijklmnopqrstuvwxyz',
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
digits: '0123456789',
symbols:'!#$%&*+-=?@_',
};
/* Liste de mots française (ASCII, 4-8 lettres) pour les passphrases */
const WORDS = ('avion armee arbre argent atelier aurore avenir badge balcon bambou banane barque bassin bateau belier beurre bidon bijou bilan biscuit bitume blague bois bonbon bordure bouche bougie boulon bouquet bourse boussole bouton branche brique brume bureau cabane cable cactus cadre caisse calcul calibre camion canard canyon capitale carbone carreau carton casque castor cerise chaine chaleur chameau champ chanson charbon chariot chateau chemin cheval chiffre cirque citron climat cloche clou cobra cochon colline colonne combat comete concert corail corde cornet coton coude courbe course couteau crabe craie crayon crochet cuivre culture cygne dauphin depart desert dessin detour dilemme disque dossier douane douche dragon drapeau echelle eclair ecorce ecran effort eglise elan email empire encre enfant epaule eponge equipe erable escale espace esprit etage etoile etude fable falaise farine faucon fenetre ferme fibre figure filet flamme fleche fleur foret fortune fosse foudre fougere four fourmi fraise fresque fromage fruit fusee galaxie galet gant garage gazon geste girafe givre glace gorge goutte graine granit gravier griffe grotte guepe guide guitare hamac hangar herbe hibou hiver homard horizon hotel humble igloo impact instant iris ivoire jambon jardin jetee jeton joueur jument jungle kayak lagune lampe langue lapin lavande legume lettre levier lezard liane lierre lilas limace lingot lion litre livre losange lueur lumiere lundi lutin machine magie maison manche manoir marbre marche marin marmite marron masque matin medaille melange melon menthe mesure meteore metier meuble miette mineur miroir module moulin muraille muscle musique navire neige nickel noyau nuage ocean oiseau ombre onde orange orbite ortie oursin outil ouvrage palier panda panier papier parfum pastel patte paume pedale peigne peintre pelote pendule pensee pepite perle pétale phare phrase piano pierre pilote pinceau piscine piste placard plage planete plante plateau plume poesie poids poire pomme pompier portail poste poterie poulet poumon prairie prisme profil prune pupitre quartz quille rabot racine radeau rafale raisin rampe rapide record refuge regard relais renard requin reseau ruban ruche rythme sable safran saison salade salon sapin satin saumon savane savon sculpte secret sentier sirop soldat sommet sonde sortie souris sphere stade statue studio stylo sucre tabac tableau talon tamis tapis taupe taureau tempete temple tenaille terrain tissu toison tortue toucan toupie tour trace train trapeze tresor tribu tricot trombone trophee tulipe tunnel turbine usine vague vallee vapeur veste viande vigne village violon vitrail vitre voile voiture volcan voyage wagon zebre zenith'
).split(' ').filter(w => /^[a-z]{4,9}$/.test(w));
/* ---------------- génération ---------------- */
let MODE = 'password';
let CURRENT = ''; // valeur générée courante (chaîne complète)
/* profil du mode charset actif (mot de passe / temporaire) */
function curProfile(){ return PREFS.profiles[MODE === 'temp' ? 'temp' : 'password']; }
function activePreset(){
const id = curProfile().preset;
return id ? PREFS.gen_presets.find(t => t.id === id) || null : null;
}
/* Paramètres effectifs de génération : le TEMPLATE fait foi s'il est
sélectionné (source unique — pas de dérive possible), sinon le profil manuel. */
function genParams(){
const src = activePreset() || curProfile();
return {
len: Math.max(1, Math.min(128, parseInt(src.len) || 8)),
prefix: src.prefix || '',
lower: !!src.lower, upper: !!src.upper, digits: !!src.digits, symbols: !!src.symbols,
};
}
function activePrefix(){
return MODE === 'passphrase' ? '' : genParams().prefix;
}
/* moteur charset commun : préfixe + N caractères tirés UNIQUEMENT des classes cochées */
function genCharset(g){
const sets = [];
if(g.lower) sets.push(SETS.lower);
if(g.upper) sets.push(SETS.upper);
if(g.digits) sets.push(SETS.digits);
if(g.symbols) sets.push(SETS.symbols);
if(!sets.length) sets.push(SETS.lower);
const pool = sets.join('');
// au moins un caractère de chaque classe cochée (dans la limite de la longueur)
const chars = sets.slice(0, g.len).map(s => pick(s));
while(chars.length < g.len) chars.push(pick(pool));
return (g.prefix || '') + shuffle(chars).join('');
}
function genPassphrase(){
const n = Math.max(2, Math.min(10, PREFS.pp_words || 4));
const sep = PREFS.pp_sep !== undefined && PREFS.pp_sep !== '' ? PREFS.pp_sep : '-';
let words = [];
for(let i = 0; i < n; i++){
let w = WORDS[randInt(WORDS.length)];
if(PREFS.pp_cap) w = w[0].toUpperCase() + w.slice(1);
words.push(w);
}
// un chiffre ajouté à la fin de chaque mot
if(PREFS.pp_digit_each) words = words.map(w => w + randInt(10));
// un caractère spécial inséré (position aléatoire) dans X mots distincts tirés au hasard.
// On exclut le(s) caractère(s) du séparateur pour ne pas rendre les mots ambigus.
const x = Math.max(0, Math.min(n, parseInt(PREFS.pp_sym_words) || 0));
if(x > 0){
const symPool = ([...SETS.symbols].filter(c => !sep.includes(c)).join('')) || SETS.symbols;
const idx = shuffle([...words.keys()]).slice(0, x);
for(const i of idx){
const w = words[i];
const pos = randInt(w.length + 1);
words[i] = w.slice(0, pos) + pick(symPool) + w.slice(pos);
}
}
return words.join(sep);
}
function regen(){
if(MODE === 'passphrase') CURRENT = genPassphrase();
else CURRENT = genCharset(genParams());
renderResult();
if(MODE === 'temp') renderMailPreview();
}
/* coloration : préfixe en bleu, chiffres/symboles colorés.
IMPORTANT : on échappe caractère par caractère PENDANT la coloration.
(Colorer après un esc() global re-matcherait le '&' de '&amp;' et casserait l'entité.) */
function renderResult(){
const el = document.getElementById('resultPw');
const esc = c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;' }[c] || c);
const pfx = activePrefix();
let rest = CURRENT, html = '';
if(pfx && CURRENT.startsWith(pfx)){
html += '<span class="pfx">' + [...pfx].map(esc).join('') + '</span>';
rest = CURRENT.slice(pfx.length);
}
for(const ch of rest){
const e = esc(ch);
if(/[0-9]/.test(ch)) html += '<span class="d">' + e + '</span>';
else if(/[!#$%&*+\-=?@_]/.test(ch)) html += '<span class="s">' + e + '</span>';
else html += e;
}
el.innerHTML = html || '—';
renderEntropy();
}
function entropyBits(){
if(MODE === 'passphrase'){
const n = PREFS.pp_words || 4;
let bits = n * Math.log2(WORDS.length);
if(PREFS.pp_digit_each) bits += n * Math.log2(10);
const x = Math.max(0, Math.min(n, parseInt(PREFS.pp_sym_words) || 0));
if(x > 0) bits += x * Math.log2(SETS.symbols.length);
return bits;
}
const g = genParams();
let pool = 0;
if(g.lower) pool += 26;
if(g.upper) pool += 26;
if(g.digits) pool += 10;
if(g.symbols) pool += SETS.symbols.length;
if(!pool) pool = 26;
return g.len * Math.log2(pool);
}
function renderEntropy(){
const bits = entropyBits();
const pct = Math.min(100, bits / 128 * 100);
const bar = document.getElementById('meterBar');
bar.style.width = pct + '%';
bar.style.background = bits < 45 ? 'var(--danger)' : bits < 75 ? '#e8b34b' : 'var(--ok)';
const label = bits < 45 ? 'faible' : bits < 75 ? 'correcte' : bits < 110 ? 'forte' : 'excellente';
document.getElementById('entropyTxt').textContent =
'≈ ' + Math.round(bits) + ' bits dentropie — robustesse ' + label +
(activePrefix() ? ' (préfixe non compté)' : '');
}
async function copyResult(){
if(!CURRENT) return;
try{ await navigator.clipboard.writeText(CURRENT); showToast('Copié dans le presse-papier'); }
catch(e){ showToast('Copie impossible — sélectionnez le texte', 'warn'); }
}
/* ---------------- modes / options ---------------- */
function setMode(m){
MODE = m;
document.querySelectorAll('.mode-tab').forEach(b => b.classList.toggle('on', b.dataset.mode === m));
const charset = m !== 'passphrase';
document.getElementById('tplBar').style.display = charset ? '' : 'none';
document.getElementById('opts-charset').style.display = charset ? '' : 'none';
document.getElementById('opts-passphrase').style.display = m === 'passphrase' ? '' : 'none';
document.getElementById('mail-temp').style.display = m === 'temp' ? '' : 'none';
document.getElementById('charsetTitle').textContent =
m === 'temp' ? 'Options — mot de passe temporaire' : 'Options — mot de passe aléatoire';
syncGeneratorInputs();
if(m === 'temp') renderVarInputs(); // construit les champs variables du template actif
regen();
}
/* ----- templates de génération (préréglages) ----- */
function renderPresetSelect(){
const sel = document.getElementById('genPreset');
if(!sel) return;
const esc = s => String(s || '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
sel.innerHTML = '<option value="">(Personnalisé)</option>' +
PREFS.gen_presets.map(t => '<option value="' + t.id + '">' + esc(t.label) + '</option>').join('');
sel.value = activePreset() ? curProfile().preset : '';
}
/* sélectionne un template pour le mode actif — le template pilote la génération
(genParams en fait la source de vérité, aucune copie donc aucune dérive possible) */
function applyPreset(id){
curProfile().preset = id || '';
savePrefs();
syncGeneratorInputs();
regen();
}
/* copie les réglages du preset actif dans le profil, puis le détache (→ Personnalisé) */
function detachPreset(){
const p = curProfile();
const t = activePreset();
if(t){ p.len = parseInt(t.len)||8; p.prefix = t.prefix||''; p.lower=!!t.lower; p.upper=!!t.upper; p.digits=!!t.digits; p.symbols=!!t.symbols; }
p.preset = '';
}
/* longueur : depuis le slider OU le champ numérique */
function onCharsetLen(v){
detachPreset();
curProfile().len = Math.max(1, Math.min(128, parseInt(v) || 8));
savePrefs();
syncGeneratorInputs();
regen();
}
/* charset/préfixe : toute modif manuelle repasse en (Personnalisé) */
function onCharsetOpt(){
detachPreset();
const p = curProfile();
p.prefix = document.getElementById('genPrefix').value;
p.lower = document.getElementById('genLower').checked;
p.upper = document.getElementById('genUpper').checked;
p.digits = document.getElementById('genDigits').checked;
p.symbols = document.getElementById('genSymbols').checked;
savePrefs();
syncGeneratorInputs();
regen();
}
function onPpOpt(){
PREFS.pp_words = parseInt(document.getElementById('ppWords').value) || 4;
document.getElementById('ppLenVal').textContent = PREFS.pp_words;
PREFS.pp_sep = document.getElementById('ppSep').value || '-';
PREFS.pp_cap = document.getElementById('ppCap').checked;
PREFS.pp_digit_each = document.getElementById('ppDigitEach').checked;
const symEl = document.getElementById('ppSymWords');
symEl.max = PREFS.pp_words; // cap sur le nb de mots
PREFS.pp_sym_words = Math.max(0, Math.min(PREFS.pp_words, parseInt(symEl.value) || 0));
symEl.value = PREFS.pp_sym_words;
savePrefs(); regen();
}
function syncGeneratorInputs(){
const g = genParams(); // valeurs effectives : template actif sinon profil manuel
document.getElementById('genLen').value = Math.min(64, g.len);
document.getElementById('genLenNum').value = g.len;
document.getElementById('genLenVal').textContent = g.len + ' caractères';
document.getElementById('genPrefix').value = g.prefix || '';
document.getElementById('genLower').checked = g.lower;
document.getElementById('genUpper').checked = g.upper;
document.getElementById('genDigits').checked = g.digits;
document.getElementById('genSymbols').checked = g.symbols;
document.getElementById('ppWords').value = PREFS.pp_words;
document.getElementById('ppLenVal').textContent = PREFS.pp_words;
document.getElementById('ppSep').value = PREFS.pp_sep;
document.getElementById('ppCap').checked = PREFS.pp_cap;
document.getElementById('ppDigitEach').checked = PREFS.pp_digit_each;
const symEl = document.getElementById('ppSymWords');
symEl.max = PREFS.pp_words;
symEl.value = Math.max(0, Math.min(PREFS.pp_words, PREFS.pp_sym_words || 0));
renderPresetSelect();
}
/* ---------------- navigation ---------------- */
/* Une seule page (le générateur) ; les réglages sont dans Préférences CMDB. */
function nav(page){
document.querySelectorAll('.page').forEach(p => p.classList.toggle('active', p.id === 'page-' + page));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.page === page));
}
/* ==========================================================================
Persistance dans la base partagée cmdb.db (table settings).
- Les préférences du générateur (templates email, presets de génération,
SMTP, réglages) sont stockées sous la clé settings['pwgen_prefs'] (JSON).
- La config AD (clés ad_*) est lue dans la même table (partagée avec la CMDB).
- localStorage sert de cache/repli quand le serveur est absent (file://).
- Écriture concurrente protégée par version optimiste (409 → recharge + rejoue).
========================================================================== */
const PWGEN_KEY = 'pwgen_prefs';
let SQL = null, DB = null, dbVersion = 0;
let SERVER_MODE = location.protocol.startsWith('http');
let pushing = false, pushPending = false;
let AD_CFG = null; // config LDAP lue dans cmdb.db, ou null si indisponible
/* lit toutes les lignes de settings dans un objet {clé: valeur} */
function readSettingsMap(){
const map = {};
try{
const r = DB.exec('SELECT key, value FROM settings');
if(r.length) for(const [k, v] of r[0].values) map[k] = v;
}catch(e){ /* table settings absente */ }
return map;
}
function applyAdFromMap(map){
if(map.ad_mode === 'ldap' && map.ad_host){
AD_CFG = {
label: map.ad_label || '', host: map.ad_host,
port: parseInt(map.ad_port || '389') || 389,
uid: map.ad_uid || 'userPrincipalName',
method: map.ad_method || 'start_tls',
verify_certificates: map.ad_verify === '1',
base: map.ad_base || '',
bind_dn: map.ad_bind_dn || '', bind_password: map.ad_bind_pw || '',
filter: map.ad_filter || '',
};
}else AD_CFG = null;
}
function renderAdStatus(){
const status = document.getElementById('dbStatus');
if(status) status.textContent = !SERVER_MODE
? '● Base locale (localStorage)'
: pushing ? '⇅ Enregistrement…' : '● Base partagée (v' + dbVersion + ')';
const hint = document.getElementById('adHint');
if(hint) hint.textContent = AD_CFG ? '' :
'Recherche AD indisponible : configurez lAD dans Préférences CMDB (base partagée).';
}
/* fusionne les prefs stockées en base avec les défauts + le cache local */
function adoptPrefs(json){
try{
const remote = JSON.parse(json || '{}');
localStorage.setItem(PREF_KEY, JSON.stringify(remote)); // cache = base
PREFS = loadPrefs();
syncGeneratorInputs();
renderTplSelect();
if(MODE === 'temp'){ renderVarInputs(); renderMailPreview(); }
regen();
}catch(e){ /* JSON invalide en base : on garde les prefs courantes */ }
}
async function loadFromDb(){
if(!SERVER_MODE){ renderAdStatus(); return; }
try{
const res = await fetch('/db', { cache: 'no-store' });
if(!res.ok) throw new Error('HTTP ' + res.status);
if(!SQL) SQL = await initSqlJs({ locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f });
const buf = new Uint8Array(await res.arrayBuffer());
dbVersion = +(res.headers.get('X-DB-Version') || 0);
if(DB) DB.close();
DB = buf.length ? new SQL.Database(buf) : new SQL.Database();
const map = readSettingsMap();
applyAdFromMap(map);
if(map[PWGEN_KEY]) adoptPrefs(map[PWGEN_KEY]); // base = source de vérité
else schedulePush(); // 1ère fois : migre le cache local vers la base
}catch(e){
SERVER_MODE = false; // pas de /db (ex: python -m http.server) → repli localStorage
AD_CFG = null;
}
renderAdStatus();
}
/* enregistre PREFS dans settings['pwgen_prefs'] et pousse la base.
Debounce 600 ms : les rafales (curseur de longueur…) sont coalescées en une écriture. */
let _pushTimer = null;
function schedulePush(){
if(!SERVER_MODE || !DB){ return; }
clearTimeout(_pushTimer);
_pushTimer = setTimeout(() => {
if(pushing){ pushPending = true; return; }
doPush();
}, 600);
}
async function doPush(){
pushing = true; pushPending = false;
try{
DB.run('CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT)');
DB.run('INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',
[PWGEN_KEY, JSON.stringify(PREFS)]);
const bytes = DB.export();
const res = await fetch('/db', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream', 'X-Base-Version': String(dbVersion) },
body: bytes,
});
if(res.status === 409){
// un autre utilisateur a écrit entre-temps : on recharge, on ré-applique NOTRE clé, on rejoue
const latest = new Uint8Array(await res.arrayBuffer());
dbVersion = +(res.headers.get('X-DB-Version') || dbVersion);
if(DB) DB.close();
DB = latest.length ? new SQL.Database(latest) : new SQL.Database();
applyAdFromMap(readSettingsMap());
pushPending = true; // rejoue notre écriture sur la base fraîche
}else if(res.ok){
dbVersion = +(res.headers.get('X-DB-Version') || dbVersion + 1);
}
}catch(e){
SERVER_MODE = false; // serveur injoignable → repli localStorage silencieux
}
pushing = false;
renderAdStatus();
if(pushPending) doPush();
}
/* poll léger : si la base change côté serveur, recharge AD + prefs (hors écriture en cours) */
function startDbSync(){
if(!SERVER_MODE) return;
setInterval(async () => {
if(pushing || pushPending || !DB) return;
try{
const r = await fetch('/db/version', { cache: 'no-store' });
const { version } = await r.json();
if(version !== dbVersion){
const r2 = await fetch('/db', { cache: 'no-store' });
const buf = new Uint8Array(await r2.arrayBuffer());
dbVersion = +(r2.headers.get('X-DB-Version') || version);
if(DB) DB.close();
DB = buf.length ? new SQL.Database(buf) : new SQL.Database();
const map = readSettingsMap();
applyAdFromMap(map);
if(map[PWGEN_KEY]) adoptPrefs(map[PWGEN_KEY]);
renderAdStatus();
}
}catch(e){ /* serveur momentanément indispo */ }
}, 5000);
}
async function initDb(){ await loadFromDb(); startDbSync(); }
/* ---------------- recherche AD (typeahead) ---------------- */
let SELECTED_USER = null;
let _adTimer = null, _adSeq = 0;
function adTypeahead(q){
clearTimeout(_adTimer);
q = (q || '').trim();
if(q.length < 2){ document.getElementById('adResults').style.display = 'none'; return; }
_adTimer = setTimeout(() => adSearch(q), 280);
}
async function adSearch(q){
if(!AD_CFG){ return; }
const box = document.getElementById('adResults');
const seq = ++_adSeq;
try{
const res = await fetch('/ldap/search', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: AD_CFG, query: q, limit: 10 }),
});
const data = await res.json();
if(seq !== _adSeq) return; // réponse obsolète
if(data.error) throw new Error(data.error);
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
box.innerHTML = (data.length ? data : []).map((u, i) =>
'<div class="ac-item" onmousedown="selectUser(' + i + ')"><b>' + esc(u.displayName) + '</b>' +
'<span class="ac-sub">' + esc(u.username) + (u.mail ? ' · ' + esc(u.mail) : '') + '</span></div>'
).join('') || '<div class="ac-item" style="cursor:default;color:var(--muted)">Aucun résultat</div>';
box.style.display = '';
box._users = data;
}catch(e){
box.innerHTML = '<div class="ac-item" style="cursor:default;color:var(--danger)">Erreur : ' + String(e.message || e) + '</div>';
box.style.display = '';
}
}
/* saisie manuelle dans le champ destinataire : oublie la sélection AD précédente
(l'utilisateur cible quelqu'un d'autre — nom à chercher ou email tapé) */
function onRecipientInput(v){
SELECTED_USER = null;
refreshVarDefaults(); // le « qui » change → recharge les valeurs AD dans les champs
renderSelectedUser();
renderVarInputs();
adTypeahead(v);
renderMailPreview();
}
function selectUser(i){
const box = document.getElementById('adResults');
const u = (box._users || [])[i];
if(!u) return;
SELECTED_USER = u;
box.style.display = 'none';
document.getElementById('adQuery').value = u.displayName;
refreshVarDefaults(); // pré-remplit les champs variables depuis l'utilisateur choisi
renderSelectedUser();
renderVarInputs();
renderMailPreview();
}
function renderSelectedUser(){
const el = document.getElementById('selectedUser');
if(!SELECTED_USER){ el.innerHTML = ''; return; }
const u = SELECTED_USER;
const esc = s => String(s || '').replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const initials = (u.displayName || '?').split(/\s+/).map(w => w[0]).slice(0, 2).join('').toUpperCase();
el.innerHTML =
'<div class="user-card"><div class="uc-avatar">' + esc(initials) + '</div>' +
'<div class="uc-info"><div class="uc-name">' + esc(u.displayName) + '</div>' +
'<div class="uc-sub">' + esc(u.username) + (u.title ? ' · ' + esc(u.title) : '') + '</div></div>' +
(u.mail ? '<span class="badge ok">' + esc(u.mail) + '</span>'
: '<span class="badge warn">pas dadresse mail</span>') +
'<button class="btn ghost small" onclick="clearUser()" title="Retirer">✕</button></div>';
}
function clearUser(){
SELECTED_USER = null;
document.getElementById('adQuery').value = '';
refreshVarDefaults();
renderSelectedUser();
renderVarInputs();
renderMailPreview();
}
/* ---------------- templates / aperçu / envoi ---------------- */
/* Destinataire résolu : user AD sélectionné → son mail ;
sinon email tapé directement dans le champ ; sinon vide (email sans destinataire). */
function typedEmail(){
const q = (document.getElementById('adQuery').value || '').trim();
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q) ? q : '';
}
function mailRecipient(){
if(SELECTED_USER && SELECTED_USER.mail) return SELECTED_USER.mail;
return typedEmail();
}
/* ---------- variables éditables du template ----------
Chaque {{ variable }} d'un template (hors tmp_password, piloté par le
générateur) devient un champ éditable, pré-rempli depuis l'AD mais modifiable.
VAR_VALUES est la source de vérité du rendu (aperçu + envoi). */
let VAR_VALUES = {};
const VAR_LABELS = { prenom:'Prénom', nom:'Nom', displayName:'Nom complet', username:'Identifiant', mail:'Email' };
/* valeurs déduites de l'utilisateur AD sélectionné (ou de l'email tapé) */
function adDefaults(){
const u = SELECTED_USER || {};
const dn = u.displayName || '';
const parts = dn.split(/\s+/).filter(Boolean);
return {
prenom: parts[0] || '',
nom: parts.slice(1).join(' '),
displayName: dn,
username: u.username || '',
mail: u.mail || typedEmail(),
};
}
/* variables présentes dans le template (objet + corps), hors tmp_password */
function tplVars(tpl){
const set = new Set();
const re = /\{\{\s*(\w+)\s*\}\}/g;
for(const s of [tpl.subject || '', tpl.body || '']){
let m; while(m = re.exec(s)) if(m[1] !== 'tmp_password') set.add(m[1]);
}
return [...set];
}
/* met à jour les champs connus de l'AD, conserve les valeurs saisies à la main
pour les variables « custom » (ex : {{ service }}) */
function refreshVarDefaults(){ Object.assign(VAR_VALUES, adDefaults()); }
/* (re)construit les inputs — à n'appeler que sur changement de template / user,
jamais pendant la frappe (sinon perte de focus) */
function renderVarInputs(){
const field = document.getElementById('varInputsField');
const wrap = document.getElementById('varInputs');
if(!wrap) return;
const tpl = currentTpl();
const vars = tpl ? tplVars(tpl) : [];
const def = adDefaults();
vars.forEach(v => { if(!(v in VAR_VALUES)) VAR_VALUES[v] = def[v] || ''; }); // amorce
if(!vars.length){ field.style.display = 'none'; wrap.innerHTML = ''; return; }
field.style.display = '';
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
wrap.innerHTML = '<div class="field-row" style="flex-wrap:wrap;gap:10px">' +
vars.map(v => '<div class="field" style="flex:1;min-width:150px;margin:0">' +
'<label>' + esc(VAR_LABELS[v] || v) + '</label>' +
'<input data-var="' + esc(v) + '" value="' + esc(VAR_VALUES[v]) + '" oninput="onVarInput(this)" autocomplete="off"></div>').join('') +
'</div>';
}
/* frappe dans un champ variable : MAJ valeur + aperçu seul (préserve le focus) */
function onVarInput(el){
VAR_VALUES[el.dataset.var] = el.value;
renderMailPreview();
}
/* changement de template : le jeu de variables peut différer → on reconstruit */
function onTemplateChange(){ renderVarInputs(); renderMailPreview(); }
/* contexte de rendu : valeurs AD écrasées par les valeurs éditées + mot de passe */
function tplContext(){
return { ...adDefaults(), ...VAR_VALUES, tmp_password: CURRENT || '' };
}
function fillTpl(text, ctx){
return String(text || '').replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => (k in ctx ? ctx[k] : m));
}
function escHtml(s){ return String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
/* Remplit un template en HTML : tout échappé, le mot de passe rendu en <code> gras. */
function fillTplHtml(text, ctx){
return escHtml(String(text || '')).replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => {
if(!(k in ctx)) return m;
const v = escHtml(ctx[k]);
return k === 'tmp_password'
? '<strong><code style="font-family:monospace;background:#f2f2f2;padding:2px 5px;border-radius:4px">' + v + '</code></strong>'
: v;
});
}
/* ==========================================================================
Rendu email HTML enrichi (markdown léger → HTML « email-safe » à styles
inline). Partagé à l'identique avec la page Préférences (éditeur + aperçu).
Syntaxe : :::banner [#couleur] … :::, ![alt](url|dataURI), # titres,
listes - / 1., ``` bloc de code ```, **gras**, `code`, [lien](url), ---.
Les {{ placeholders }} sont substitués EN DERNIER (valeurs échappées, jamais
réinterprétées) — un mot de passe contenant * ou ` ne casse donc rien.
========================================================================== */
function emailEsc(s){ return String(s == null ? '' : s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
/* URL sûre en attribut : whitelist de scheme + échappe les guillemets.
Neutralise javascript:, data:text/html, vbscript:… → « # » si scheme non autorisé.
(emailEsc a déjà neutralisé & < > ; il reste les " qui casseraient l'attribut.) */
function emailUrl(u){
const s = String(u == null ? '' : u).trim();
const sch = /^([a-z][a-z0-9+.\-]*):/i.exec(s);
const ok = !sch || /^(https?|mailto|tel)$/i.test(sch[1]) || /^data:image\//i.test(s);
return (ok ? s : '#').replace(/"/g, '&quot;');
}
function emailFillVars(html, ctx){
return html.replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => {
if(!(k in ctx)) return m;
const v = emailEsc(ctx[k]);
return k === 'tmp_password'
? '<strong><code style="font-family:ui-monospace,Consolas,monospace;background:#f2f2f2;color:#222;padding:2px 6px;border-radius:4px;font-size:14px">' + v + '</code></strong>'
: v;
});
}
function renderEmailHtml(src, ctx){
const lines = emailEsc(src).split('\n');
const inline = t => t
.replace(/`([^`]+)`/g, '<code style="font-family:ui-monospace,Consolas,monospace;background:#f2f2f2;color:#222;padding:1px 5px;border-radius:4px">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (mm, txt, url) => '<a href="' + emailUrl(url) + '" style="color:#2f6fd8;text-decoration:underline">' + txt + '</a>');
let html = '', i = 0, m;
while(i < lines.length){
const line = lines[i];
if(m = line.match(/^:::\s*banner\s*(#[0-9a-fA-F]{3,8})?\s*$/)){
const color = m[1] || '#5b9cf5', buf = [];
for(i++; i < lines.length && !/^:::\s*$/.test(lines[i]); i++) buf.push(lines[i]);
i++;
html += '<div style="background:' + color + ';color:#fff;padding:24px 28px;border-radius:18px;font-size:20px;font-weight:700;line-height:1.35;margin:0 0 18px">' + inline(buf.join('<br>')) + '</div>';
continue;
}
if(/^```/.test(line)){
const buf = [];
for(i++; i < lines.length && !/^```/.test(lines[i]); i++) buf.push(lines[i]);
i++;
html += '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 16px"><tr><td style="padding:0"><pre style="margin:0;background:#1d2026;color:#e6e6e6;padding:12px 14px;border-radius:14px;overflow-x:auto;font-family:ui-monospace,Consolas,monospace;font-size:13px;line-height:1.55"><code>' + buf.join('\n') + '</code></pre></td></tr></table>';
continue;
}
if(m = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/)){
html += '<div style="margin:0 0 16px"><img src="' + emailUrl(m[2]) + '" alt="' + m[1].replace(/"/g, '&quot;') + '" style="max-width:100%;height:auto;border-radius:8px;display:block"></div>';
i++; continue;
}
if(/^---+\s*$/.test(line)){ html += '<hr style="border:0;border-top:1px solid #e2e2e2;margin:20px 0">'; i++; continue; }
if(m = line.match(/^(#{1,3})\s+(.*)$/)){
const lvl = m[1].length, sz = lvl === 1 ? '22px' : lvl === 2 ? '18px' : '15px';
html += '<h' + lvl + ' style="font-size:' + sz + ';font-weight:700;color:#1a1a1a;margin:16px 0 12px">' + inline(m[2]) + '</h' + lvl + '>';
i++; continue;
}
if(/^[-*]\s+/.test(line)){
const items = [];
for(; i < lines.length && /^[-*]\s+/.test(lines[i]); i++) items.push(inline(lines[i].replace(/^[-*]\s+/, '')));
html += '<ul style="margin:0 0 16px;padding-left:22px;color:#333">' + items.map(it => '<li style="margin:0 0 6px;line-height:1.5">' + it + '</li>').join('') + '</ul>';
continue;
}
if(/^\d+\.\s+/.test(line)){
const items = [];
for(; i < lines.length && /^\d+\.\s+/.test(lines[i]); i++) items.push(inline(lines[i].replace(/^\d+\.\s+/, '')));
html += '<ol style="margin:0 0 16px;padding-left:22px;color:#333">' + items.map(it => '<li style="margin:0 0 6px;line-height:1.5">' + it + '</li>').join('') + '</ol>';
continue;
}
if(/^\s*$/.test(line)){ i++; continue; }
const buf = [lines[i]]; i++; // consomme au moins la ligne courante (jamais de boucle infinie)
for(; i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,3}\s|[-*]\s|\d+\.\s|```|:::|---+\s*$|!\[)/.test(lines[i]); i++) buf.push(lines[i]);
html += '<p style="margin:0 0 16px;line-height:1.6;color:#333;font-size:14px">' + inline(buf.join('<br>')) + '</p>';
}
// conteneur neutre, sans fond : le mail adopte le fond par défaut du client
const doc = '<div style="max-width:600px;margin:0 auto;padding:8px 4px;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;font-size:14px;color:#333;line-height:1.6">' + html + '</div>';
return emailFillVars(doc, ctx);
}
/* HTML complet du corps du mail selon le format du template (source de vérité
pour l'aperçu et l'envoi). */
function tplBodyHtml(tpl, ctx){
return tpl.format === 'html'
? renderEmailHtml(tpl.body, ctx)
: '<div style="max-width:600px;margin:0 auto;padding:8px 4px;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;font-size:14px;color:#333;line-height:1.6">' + fillTplHtml(tpl.body, ctx).replace(/\n/g, '<br>') + '</div>';
}
function currentTpl(){
const id = document.getElementById('tplSelect').value;
return PREFS.templates.find(t => t.id === id) || PREFS.templates[0];
}
function renderTplSelect(){
const sel = document.getElementById('tplSelect');
const prev = sel.value;
sel.innerHTML = PREFS.templates.map(t =>
'<option value="' + t.id + '">' + String(t.label).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])) + '</option>').join('');
if(PREFS.templates.some(t => t.id === prev)) sel.value = prev;
}
function renderMailPreview(){
const tpl = currentTpl();
const box = document.getElementById('mailPreview');
const btn = document.getElementById('sendBtn');
const mbtn = document.getElementById('mailtoBtn');
if(!tpl){
box.innerHTML = '<span style="color:var(--muted)">Aucun template email.</span>';
btn.disabled = true; mbtn.disabled = true;
return;
}
const ctx = tplContext();
const esc = s => String(s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const to = mailRecipient();
const toLine = '<div class="pv-subject" style="border:0;padding:0;margin-bottom:4px">À : ' +
(to ? esc(to) : '<span style="color:var(--muted)">(vide — à compléter dans lapp mail)</span>') + '</div>';
// rendu direct du mail : les templates n'ont pas de fond propre ; l'aperçu les
// pose sur du blanc pour simuler le client mail (texte sombre lisible).
const rendered = tpl.format === 'html'
? '<div style="background:#ffffff;border-radius:8px;padding:16px;margin-top:8px;white-space:normal">' + renderEmailHtml(tpl.body, ctx) + '</div>'
: fillTplHtml(tpl.body, ctx);
const atts = tpl.attachments || [];
const attLine = atts.length
? '<div style="margin-top:10px;color:var(--muted);font-size:12px">📎 ' + atts.length + ' pièce(s) jointe(s) : ' +
atts.map(a => esc(a.name)).join(', ') + '</div>'
: '';
box.innerHTML = toLine +
'<div class="pv-subject">Objet : ' + esc(fillTpl(tpl.subject, ctx)) + '</div>' + rendered + attLine;
// mailto : toujours possible (destinataire pré-rempli si connu, sinon vide) ;
// envoi SMTP : nécessite un destinataire réel.
mbtn.disabled = !CURRENT;
btn.disabled = !to || !CURRENT;
}
/* ouvre le client mail par défaut (mailto:), message pré-rempli — pas de SMTP requis.
Destinataire : user AD, sinon email tapé, sinon vide (email sans destinataire). */
function openMailApp(){
const tpl = currentTpl();
if(!tpl || !CURRENT) return;
const ctx = tplContext();
const to = mailRecipient();
location.href = 'mailto:' + encodeURIComponent(to) +
'?subject=' + encodeURIComponent(fillTpl(tpl.subject, ctx)) +
'&body=' + encodeURIComponent(fillTpl(tpl.body, ctx));
}
async function sendMail(){
const tpl = currentTpl();
const to = mailRecipient();
if(!tpl || !to || !CURRENT) return;
if(!PREFS.smtp.host){
showToast('Serveur SMTP non configuré — voir Paramètres', 'warn');
return;
}
if(!confirm('Envoyer le mot de passe temporaire à ' + to + ' ?')) return;
const ctx = tplContext();
const btn = document.getElementById('sendBtn');
btn.disabled = true; btn.textContent = '… envoi';
try{
const res = await fetch('/mail/send', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
smtp: PREFS.smtp,
to: to,
subject: fillTpl(tpl.subject, ctx),
body: fillTpl(tpl.body, ctx), // alternative texte brut (placeholders substitués)
html: '<!doctype html><html><body style="margin:0;padding:16px">'
+ tplBodyHtml(tpl, ctx) + '</body></html>',
attachments: (tpl.attachments || []).map(a => a.url), // pièces jointes du template
}),
});
const data = await res.json();
if(!data.ok) throw new Error(data.error || 'échec de lenvoi');
showToast('Mail envoyé à ' + to);
}catch(e){
showToast('Envoi impossible : ' + String(e.message || e), 'warn');
}finally{
btn.textContent = '✉ Envoyer le mot de passe';
renderMailPreview();
}
}
function closeModal(){ document.getElementById('overlay').classList.remove('open'); }
document.getElementById('overlay').addEventListener('click', e => { if(e.target.id === 'overlay') closeModal(); });
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
/* ---------------- toasts ---------------- */
function showToast(msg, kind){
const wrap = document.getElementById('toastWrap');
const el = document.createElement('div');
el.className = 'toast' + (kind === 'warn' ? ' warn' : '');
el.textContent = msg;
wrap.appendChild(el);
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; setTimeout(() => el.remove(), 320); }, 3200);
}
/* ---------------- init ---------------- */
syncGeneratorInputs();
renderTplSelect();
regen();
initDb(); // charge la config AD + les prefs depuis la base partagée, active la sync
</script>
</body>
</html>