From 1e52006b46b94e7addc8251bb1c1e707ff906b1a Mon Sep 17 00:00:00 2001 From: stef Date: Sun, 7 Dec 2025 10:24:28 +0100 Subject: [PATCH] feat(webui): add minimal SPA for managing CAs and certificates --- webui/index.html | 61 +++++++++------ webui/main.js | 198 +++++++++++++++++++++++++++++++++++++++++++++++ webui/style.css | 17 ++++ 3 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 webui/main.js create mode 100644 webui/style.css diff --git a/webui/index.html b/webui/index.html index 26289c8..1871d66 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,28 +1,43 @@ - - - - PKI API - Web UI (Scaffold) - - - -

PKI API - Web UI (Scaffold)

-

Cette page est un squelette minimal. L'interface web sera développée pour gérer les CAs et certificats via l'API REST.

+ + + + PKI API - Web UI + + + +
+
+

PKI API - Web UI (Minimal)

+
+ + + +
+ +
-
-

Actions rapides (exemples)

-
    -
  • Connexion via JWT
  • -
  • Liste des CAs
  • -
  • Créer une CA
  • -
  • Créer / signer un certificat
  • -
-
+
+ -

Pour démarrer le développement, ouvrir `webui/README.md`.

- + + + + + +
+ +
+ Consomme l'API sur /api/v1. Assurez-vous d'entrer un token JWT valide. +
+
+ + + diff --git a/webui/main.js b/webui/main.js new file mode 100644 index 0000000..cadc63b --- /dev/null +++ b/webui/main.js @@ -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 }; +})(); diff --git a/webui/style.css b/webui/style.css new file mode 100644 index 0000000..82bc568 --- /dev/null +++ b/webui/style.css @@ -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}