feat(webui): add minimal SPA for managing CAs and certificates

main
stef 2025-12-07 10:24:28 +01:00
parent 028f768299
commit 1e52006b46
3 changed files with 253 additions and 23 deletions

View File

@ -1,28 +1,43 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PKI API - Web UI (Scaffold)</title>
<style>
body{font-family:system-ui,Arial;margin:2rem}
.card{border:1px solid #ddd;padding:1rem;border-radius:6px}
</style>
</head>
<body>
<h1>PKI API - Web UI (Scaffold)</h1>
<p>Cette page est un squelette minimal. L'interface web sera développée pour gérer les CAs et certificats via l'API REST.</p>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PKI API - Web UI</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="container">
<header>
<h1>PKI API - Web UI (Minimal)</h1>
<div class="token-row">
<label for="token">JWT Token:</label>
<input id="token" placeholder="Entrez le token JWT ici" />
<button id="saveToken">Enregistrer</button>
</div>
<nav>
<button id="btnCAs">Liste des CAs</button>
<button id="btnCerts">Liste des certificats</button>
<button id="btnCreateCA">Créer CA</button>
<button id="btnCreateCert">Créer Certificat</button>
</nav>
</header>
<div class="card">
<h2>Actions rapides (exemples)</h2>
<ul>
<li>Connexion via JWT</li>
<li>Liste des CAs</li>
<li>Créer une CA</li>
<li>Créer / signer un certificat</li>
</ul>
</div>
<main>
<section id="message" class="card hidden"></section>
<p>Pour démarrer le développement, ouvrir `webui/README.md`.</p>
</body>
<section id="list" class="card hidden"></section>
<section id="form" class="card hidden"></section>
<section id="details" class="card hidden"></section>
</main>
<footer>
<small>Consomme l'API sur <code>/api/v1</code>. Assurez-vous d'entrer un token JWT valide.</small>
</footer>
</div>
<script src="./main.js"></script>
</body>
</html>

198
webui/main.js 100644
View File

@ -0,0 +1,198 @@
(function(){
const API_BASE = '/api/v1';
let token = localStorage.getItem('pki_token') || '';
function $(id){ return document.getElementById(id); }
function show(el){ el.classList.remove('hidden'); }
function hide(el){ el.classList.add('hidden'); }
function showMessage(msg, isError){
const m = $('message');
m.textContent = msg;
m.className = 'card';
if(isError) m.classList.add('error');
show(m);
setTimeout(()=>{ hide(m); }, 7000);
}
function apiFetch(path, opts={}){
opts.headers = opts.headers || {};
opts.headers['Content-Type'] = 'application/json';
if(token) opts.headers['Authorization'] = 'Bearer ' + token;
return fetch(API_BASE + path, opts).then(async res => {
const ct = res.headers.get('content-type') || '';
if(!res.ok){
let body = await res.text();
throw new Error(body || res.statusText);
}
if(ct.includes('application/json')) return res.json();
return res.blob();
});
}
function renderTable(items, type){
const list = $('list');
list.innerHTML = '';
const h = document.createElement('h3');
h.textContent = type === 'ca' ? 'Autorités de Certification' : 'Certificats';
list.appendChild(h);
const table = document.createElement('table');
table.className = 'table';
const thead = document.createElement('thead');
const headRow = document.createElement('tr');
['ID','Sujet','Émetteur','Not After','Actions'].forEach(t=>{ const th=document.createElement('th'); th.textContent=t; headRow.appendChild(th); });
thead.appendChild(headRow);
table.appendChild(thead);
const tbody = document.createElement('tbody');
items.forEach(it=>{
const tr = document.createElement('tr');
const idCell = document.createElement('td'); idCell.textContent = it.id || it.ID || it.Id || '';
const subj = document.createElement('td'); subj.textContent = it.subject || it.Subject || it.common_name || '-';
const issuer = document.createElement('td'); issuer.textContent = it.issuer || it.issuer || '-';
const na = document.createElement('td'); na.textContent = it.not_after || it.notAfter || it.notAfter || '-';
const actions = document.createElement('td');
const btnView = document.createElement('button'); btnView.textContent = 'Voir';
btnView.onclick = ()=> viewDetails(it.id || it.ID || it.Id);
actions.appendChild(btnView);
if(type === 'cert'){
const btnExport = document.createElement('button'); btnExport.textContent = 'Export PEM';
btnExport.onclick = ()=> exportCertPEM(it.id || it.ID || it.Id);
actions.appendChild(btnExport);
const btnRevoke = document.createElement('button'); btnRevoke.textContent = 'Révoquer';
btnRevoke.onclick = ()=> revokeCert(it.id || it.ID || it.Id);
actions.appendChild(btnRevoke);
}
tr.appendChild(idCell); tr.appendChild(subj); tr.appendChild(issuer); tr.appendChild(na); tr.appendChild(actions);
tbody.appendChild(tr);
});
table.appendChild(tbody);
list.appendChild(table);
show(list);
}
function setTokenFromInput(){
const t = $('token').value.trim();
token = t;
localStorage.setItem('pki_token', token);
showMessage('Token enregistré.');
}
async function loadCAs(){
try{
const res = await apiFetch('/ca');
const cas = res.cas || res.list || res;
renderTable(cas, 'ca');
}catch(e){ showMessage('Erreur loadCAs: '+e.message, true); }
}
async function loadCerts(){
try{
const res = await apiFetch('/certificates');
const certs = res.certificates || res.list || res;
renderTable(certs, 'cert');
}catch(e){ showMessage('Erreur loadCerts: '+e.message, true); }
}
function showCreateCAForm(){
const f = $('form'); f.innerHTML = '';
const h = document.createElement('h3'); h.textContent = 'Créer une CA'; f.appendChild(h);
const subject = document.createElement('input'); subject.placeholder='CN=Root CA,O=Example,C=FR'; subject.id='ca_subject';
const days = document.createElement('input'); days.placeholder='validity_days'; days.type='number'; days.id='ca_days'; days.value=3650;
const btn = document.createElement('button'); btn.textContent='Créer'; btn.onclick = async ()=>{
try{
const body = { subject: subject.value, validity_days: parseInt(days.value||0,10) };
const res = await apiFetch('/ca',{ method:'POST', body: JSON.stringify(body) });
showMessage('CA créée: '+(res.ca && res.ca.id));
loadCAs();
}catch(e){ showMessage('Erreur création CA: '+e.message, true); }
};
f.appendChild(subject); f.appendChild(document.createElement('br'));
f.appendChild(days); f.appendChild(document.createElement('br'));
f.appendChild(btn);
show(f);
}
function showCreateCertForm(){
const f = $('form'); f.innerHTML = '';
const h = document.createElement('h3'); h.textContent = 'Créer un Certificat (auto-signé)'; f.appendChild(h);
const subject = document.createElement('input'); subject.placeholder='CN=server.example.com,O=Example,C=FR'; subject.id='cert_subject';
const days = document.createElement('input'); days.placeholder='validity_days'; days.type='number'; days.id='cert_days'; days.value=365;
const btn = document.createElement('button'); btn.textContent='Créer'; btn.onclick = async ()=>{
try{
const body = { subject: subject.value, validity_days: parseInt(days.value||0,10) };
const res = await apiFetch('/certificates',{ method:'POST', body: JSON.stringify(body) });
showMessage('Certificat créé: '+(res.certificate && res.certificate.id));
loadCerts();
}catch(e){ showMessage('Erreur création cert: '+e.message, true); }
};
f.appendChild(subject); f.appendChild(document.createElement('br'));
f.appendChild(days); f.appendChild(document.createElement('br'));
f.appendChild(btn);
show(f);
}
async function viewDetails(id){
try{
const res = await apiFetch('/certificates/'+id);
const cert = res.certificate || res;
const d = $('details');
d.innerHTML = '';
const h = document.createElement('h3'); h.textContent = 'Détails Certificat'; d.appendChild(h);
const pre = document.createElement('pre'); pre.textContent = JSON.stringify(cert, null, 2);
d.appendChild(pre);
show(d);
}catch(e){
// peut-être une CA
try{
const res = await apiFetch('/ca/'+id);
const ca = res.ca || res;
const d = $('details'); d.innerHTML=''; d.appendChild(document.createElement('h3')).textContent='Détails CA';
const pre = document.createElement('pre'); pre.textContent = JSON.stringify(ca, null, 2); d.appendChild(pre); show(d);
}catch(er){ showMessage('Erreur viewDetails: '+er.message, true); }
}
}
async function revokeCert(id){
if(!confirm('Confirmer la révocation du certificat '+id+' ?')) return;
try{
const body = { certificate_id: id, reason: 'Revoked via UI' };
await apiFetch('/revoke',{ method:'POST', body: JSON.stringify(body) });
showMessage('Certificat révoqué: '+id);
loadCerts();
}catch(e){ showMessage('Erreur revoke: '+e.message, true); }
}
async function exportCertPEM(id){
try{
const blob = await apiFetch('/certificates/'+id+'/export/pem');
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = id + '.pem'; document.body.appendChild(a); a.click(); a.remove();
window.URL.revokeObjectURL(url);
}catch(e){ showMessage('Erreur export: '+e.message, true); }
}
function attachEvents(){
$('saveToken').onclick = setTokenFromInput;
$('btnCAs').onclick = loadCAs;
$('btnCerts').onclick = loadCerts;
$('btnCreateCA').onclick = showCreateCAForm;
$('btnCreateCert').onclick = showCreateCertForm;
// prefills
$('token').value = token;
}
// init
document.addEventListener('DOMContentLoaded', ()=>{
attachEvents();
if(token) showMessage('Token chargé depuis localStorage.');
});
// export functions for debugging
window.pkiUI = { loadCAs, loadCerts, viewDetails };
})();

17
webui/style.css 100644
View File

@ -0,0 +1,17 @@
:root{--bg:#f7f9fb;--card:#fff;--accent:#2b6cb0}
body{font-family:Inter,system-ui,Segoe UI,Arial;background:var(--bg);margin:0;padding:1rem}
.container{max-width:1000px;margin:0 auto}
header{display:flex;flex-direction:column;gap:0.5rem}
.token-row{display:flex;gap:0.5rem;align-items:center}
nav{display:flex;gap:0.5rem}
button{background:var(--accent);color:#fff;border:none;padding:0.4rem 0.6rem;border-radius:4px;cursor:pointer}
button.secondary{background:#666}
.card{background:var(--card);padding:1rem;border-radius:6px;box-shadow:0 1px 2px rgba(0,0,0,0.04);margin-top:1rem}
.hidden{display:none}
.table{width:100%;border-collapse:collapse;margin-top:0.5rem}
.table th,.table td{padding:0.5rem;border-bottom:1px solid #eee;text-align:left}
pre{white-space:pre-wrap;word-break:break-word}
.error{border-left:4px solid #e53e3e;padding-left:0.8rem}
label{font-weight:600}
input{padding:0.4rem;border:1px solid #ddd;border-radius:4px}
footer{margin-top:1rem;color:#666}