feat(webui): add minimal SPA for managing CAs and certificates
parent
028f768299
commit
1e52006b46
|
|
@ -1,28 +1,43 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>PKI API - Web UI (Scaffold)</title>
|
<title>PKI API - Web UI</title>
|
||||||
<style>
|
<link rel="stylesheet" href="./style.css">
|
||||||
body{font-family:system-ui,Arial;margin:2rem}
|
</head>
|
||||||
.card{border:1px solid #ddd;padding:1rem;border-radius:6px}
|
<body>
|
||||||
</style>
|
<div class="container">
|
||||||
</head>
|
<header>
|
||||||
<body>
|
<h1>PKI API - Web UI (Minimal)</h1>
|
||||||
<h1>PKI API - Web UI (Scaffold)</h1>
|
<div class="token-row">
|
||||||
<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>
|
<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">
|
<main>
|
||||||
<h2>Actions rapides (exemples)</h2>
|
<section id="message" class="card hidden"></section>
|
||||||
<ul>
|
|
||||||
<li>Connexion via JWT</li>
|
<section id="list" class="card hidden"></section>
|
||||||
<li>Liste des CAs</li>
|
|
||||||
<li>Créer une CA</li>
|
<section id="form" class="card hidden"></section>
|
||||||
<li>Créer / signer un certificat</li>
|
|
||||||
</ul>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p>Pour démarrer le développement, ouvrir `webui/README.md`.</p>
|
<script src="./main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
})();
|
||||||
|
|
@ -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}
|
||||||
Loading…
Reference in New Issue