diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs index 3f818fe..ae09d41 100644 --- a/bin/ra-console/examples/e2e_console.rs +++ b/bin/ra-console/examples/e2e_console.rs @@ -72,12 +72,15 @@ async fn main() { // L'opérateur et sa clé, comme en production mais sans passer par // l'enregistrement relayé (qui a ses propres tests). let now = time::OffsetDateTime::now_utc(); - // Trois opérateurs : alice décide des demandes (étape 6b), bob et carol - // révoquent à deux (étape 6c, double contrôle). + // alice décide des demandes (étape 6b), bob et carol révoquent à deux + // (étape 6c, double contrôle). let people = [ ("alice", Role::RaOperateur), ("bob", Role::CaOperateur), ("carol", Role::CaOperateur), + // Registre (6e) : root administre, dave voit son rôle changer et sa clé révoquée. + ("root", Role::Admin), + ("dave", Role::RaOperateur), ]; let reg_verifier = verifier(); // Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index 120fbd3..4902d3e 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -18,7 +18,7 @@ use crate::audit::{self, Recorder}; use crate::ca_link::{CaLink, Relayed}; use crate::login::{LoginError, LoginService}; use crate::session::{Authenticated, SessionError, Sessions, COOKIE_NAME, SESSION_TTL}; -use crate::{certificates, quorum, requests}; +use crate::{certificates, operators, quorum, requests}; /// Assez pour un objet d'attestation, pas pour bourrer la mémoire. const MAX_BODY_BYTES: usize = 64 * 1024; @@ -57,6 +57,7 @@ pub fn router(state: Arc) -> Router { .route("/api/v1/logout", post(handle_logout)) .route("/api/v1/requests", get(handle_requests)) .route("/api/v1/certificates", get(handle_certificates)) + .route("/api/v1/operators", get(handle_operators)) .route("/api/v1/webauthn/challenge", post(handle_action_challenge)) .route("/api/v1/requests/{id}/approve", post(handle_approve)) .route("/api/v1/requests/{id}/reject", post(handle_reject)) @@ -843,6 +844,26 @@ pub(crate) fn quorum_status(body: &serde_json::Value) -> serde_json::Value { }) } +/// `GET /api/v1/operators` : le registre en lecture seule (docs/WEBUI.md §10), +/// pour toute session authentifiée ; les écritures restent des actions signées +/// que `ca-server` juge. +async fn handle_operators(State(state): State>, headers: HeaderMap) -> Response { + if let Err(resp) = authenticate(&state, &headers).await { + return resp; + } + match operators::list(&state.pool, time::OffsetDateTime::now_utc()).await { + Ok(registry) => Json(registry).into_response(), + Err(e) => { + tracing::error!(erreur = %e, "operators : base indisponible"); + error( + StatusCode::SERVICE_UNAVAILABLE, + "unavailable", + "service indisponible", + ) + } + } +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CertificatesQuery { diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index faa5ad6..3bae97d 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -19,6 +19,7 @@ pub mod config; pub mod db_guard; pub mod http; pub mod login; +pub mod operators; pub mod purge; pub mod quorum; pub mod registry_routes; diff --git a/bin/ra-console/src/operators.rs b/bin/ra-console/src/operators.rs new file mode 100644 index 0000000..fd5d301 --- /dev/null +++ b/bin/ra-console/src/operators.rs @@ -0,0 +1,119 @@ +//! `GET /api/v1/operators` (docs/WEBUI.md §10, §15 étape 6e) : le registre des +//! opérateurs, leurs clés et les clés en attente de confirmation, en lecture +//! seule sur les tables de `ca-server`. Toute écriture passe par une action +//! signée (`registry_routes`). +//! +//! L'empreinte d'une clé en attente est recalculée ici avec la fonction même +//! de `ca-server` (`oe_actions::key_fingerprint`) : l'administrateur la compare +//! hors bande à celle que l'invité a reçue à l'enregistrement (§10), puis la +//! signe ; `ca-server` la recompare à la clé stockée. Une console qui +//! afficherait une autre empreinte ferait échouer la confirmation, pas passer +//! une autre clé. + +use oe_webauthn::Uuid; +use serde::Serialize; +use sqlx::{PgPool, Row}; +use time::OffsetDateTime; + +#[derive(Debug, Serialize)] +pub struct Credential { + pub credential_id: String, + pub label: String, + #[serde(with = "time::serde::rfc3339")] + pub initiated_at: OffsetDateTime, + pub confirmed_by: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_used_at: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub revoked_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct Operator { + pub name: String, + pub role: String, + pub disabled: bool, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + pub credentials: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PendingKey { + pub credential_id: String, + pub operator: String, + /// `None` si la clé stockée ne se relit pas : rien à confirmer alors. + pub key_fingerprint: Option, + #[serde(with = "time::serde::rfc3339")] + pub registered_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, +} + +#[derive(Debug, Serialize)] +pub struct Registry { + pub operators: Vec, + pub pending: Vec, +} + +pub async fn list(pool: &PgPool, now: OffsetDateTime) -> Result { + let ops = + sqlx::query("SELECT id, name, role, created_at, disabled_at FROM operators ORDER BY name") + .fetch_all(pool) + .await?; + let creds = sqlx::query( + "SELECT credential_id, operator_id, label, initiated_at, confirmed_by, last_used_at, revoked_at + FROM webauthn_credentials ORDER BY initiated_at", + ) + .fetch_all(pool) + .await?; + let operators = ops + .iter() + .map(|o| { + let id: Uuid = o.get("id"); + Operator { + name: o.get("name"), + role: o.get("role"), + disabled: o.get::, _>("disabled_at").is_some(), + created_at: o.get("created_at"), + credentials: creds + .iter() + .filter(|c| c.get::("operator_id") == id) + .map(|c| Credential { + credential_id: c.get("credential_id"), + label: c.get("label"), + initiated_at: c.get("initiated_at"), + confirmed_by: c.get("confirmed_by"), + last_used_at: c.get("last_used_at"), + revoked_at: c.get("revoked_at"), + }) + .collect(), + } + }) + .collect(); + + let pending = sqlx::query( + "SELECT p.credential_id, o.name, p.passkey, p.registered_at, p.expires_at + FROM pending_credentials p JOIN operators o ON o.id = p.operator_id + WHERE p.expires_at > $1 + ORDER BY p.registered_at", + ) + .bind(now) + .fetch_all(pool) + .await? + .iter() + .map(|p| { + let passkey: serde_json::Value = p.get("passkey"); + PendingKey { + credential_id: p.get("credential_id"), + operator: p.get("name"), + key_fingerprint: serde_json::from_value::(passkey) + .ok() + .and_then(|k| oe_actions::key_fingerprint(&k).ok()), + registered_at: p.get("registered_at"), + expires_at: p.get("expires_at"), + } + }) + .collect(); + Ok(Registry { operators, pending }) +} diff --git a/bin/ra-console/tests/action_challenge.rs b/bin/ra-console/tests/action_challenge.rs index 855cffe..82575d9 100644 --- a/bin/ra-console/tests/action_challenge.rs +++ b/bin/ra-console/tests/action_challenge.rs @@ -74,7 +74,9 @@ impl Env { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); - let name = format!("chal_{nanos}"); + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let name = format!("chal_{nanos}_{seq}"); let admin = PgPoolOptions::new().connect(&base).await.unwrap(); sqlx::query(&format!("CREATE DATABASE {name}")) .execute(&admin) @@ -1076,6 +1078,19 @@ async fn an_admin_invites_and_confirms_an_operator_through_the_console() { assert_eq!(pending["status"], "pending_confirmation", "{pending}"); let credential_id = pending["credential_id"].as_str().unwrap().to_string(); + // La console montre la clé en attente, avec l'empreinte même que ca-server a + // remise à l'invitée : c'est elle que l'administrateur compare hors bande. + let (status, registry) = env.get("/api/v1/operators", &admin).await; + assert_eq!(status, StatusCode::OK, "{registry}"); + let shown = registry["pending"] + .as_array() + .unwrap() + .iter() + .find(|p| p["credential_id"] == credential_id.as_str()) + .unwrap_or_else(|| panic!("clé en attente absente : {registry}")); + assert_eq!(shown["operator"], "eve"); + assert_eq!(shown["key_fingerprint"], pending["key_fingerprint"]); + let confirm = serde_json::json!({ "action": "confirm_key", "credential_id": credential_id, @@ -1093,6 +1108,17 @@ async fn an_admin_invites_and_confirms_an_operator_through_the_console() { // La clé est active : l'invitée peut se connecter. let eve = env.log_in("eve").await; assert!(eve.starts_with("session=")); + let (_, registry) = env.get("/api/v1/operators", &admin).await; + assert_eq!(registry["pending"], serde_json::json!([]), "{registry}"); + let eve_entry = registry["operators"] + .as_array() + .unwrap() + .iter() + .find(|o| o["name"] == "eve") + .unwrap(); + assert_eq!(eve_entry["credentials"].as_array().unwrap().len(), 1); + let (status, _) = env.get("/api/v1/operators", "session=n-importe-quoi").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); } /// Révocation d'une clé : la cible de la route est contrôlée par ca-server. diff --git a/bin/ra-console/web/dist/console.css b/bin/ra-console/web/dist/console.css index 86b2258..fc629bc 100644 --- a/bin/ra-console/web/dist/console.css +++ b/bin/ra-console/web/dist/console.css @@ -382,3 +382,16 @@ select { max-height: 200px; font-size: 12px; } + +/* --- 6e : opérateurs --- */ +.actions.start { + justify-content: flex-start; + margin-bottom: 12px; +} +.key { + border-top: 1px solid var(--border-subtle); + padding: 8px 0; +} +.key p { + margin: 0 0 4px; +} diff --git a/bin/ra-console/web/dist/console.js b/bin/ra-console/web/dist/console.js index 7f0c3c5..0773654 100644 --- a/bin/ra-console/web/dist/console.js +++ b/bin/ra-console/web/dist/console.js @@ -1 +1 @@ -async function v(t,o,n){let i={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(i.headers={"Content-Type":"application/json"},i.body=JSON.stringify(n));let a=await fetch(o,i),s=await a.text(),u=null;if(s!=="")try{u=JSON.parse(s)}catch{u=null}return{status:a.status,body:u}}function b(t){return typeof t=="object"&&t!==null&&"error"in t}var k=["keydown","pointerdown","wheel","touchstart"];function N(t,o){let n=0,i=0,a=()=>{window.clearTimeout(n),window.clearTimeout(i),n=window.setTimeout(t,84e4),i=window.setTimeout(o,9e5)};for(let s of k)window.addEventListener(s,a,{passive:!0});return a(),()=>{window.clearTimeout(n),window.clearTimeout(i);for(let s of k)window.removeEventListener(s,a)}}function e(t,o={},...n){let i=document.createElement(t);for(let[a,s]of Object.entries(o))i.setAttribute(a,s);for(let a of n)a==null||a===!1||i.append(typeof a=="string"?document.createTextNode(a):a);return i}function y(t,...o){t.replaceChildren(...o)}var z={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function S(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},z[t.environment])}function x(t){let o=new Uint8Array(t),n="";for(let i of o)n+=String.fromCharCode(i);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function M(t){let o=t.replace(/-/g,"+").replace(/_/g,"/"),n=o+"=".repeat((4-o.length%4)%4),i=atob(n),a=new Uint8Array(i.length);for(let s=0;s({type:a.type,id:M(a.id),...a.transports?{transports:a.transports}:{}})),...t.timeout!==void 0?{timeout:t.timeout}:{},...t.rpId!==void 0?{rpId:t.rpId}:{},...t.userVerification!==void 0?{userVerification:t.userVerification}:{}},n=await navigator.credentials.get({publicKey:o});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let i=n.response;return{id:n.id,rawId:x(n.rawId),type:n.type,response:{clientDataJSON:x(i.clientDataJSON),authenticatorData:x(i.authenticatorData),signature:x(i.signature),userHandle:i.userHandle?x(i.userHandle):null},extensions:{}}}function j(t,o,n,i){let a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},i??""),s=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),u=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),p=e("form",{class:"login-form","aria-labelledby":"login-title"},e("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),e("label",{for:"operator-name"},"Nom d'op\xE9rateur"),s,u,a);p.addEventListener("submit",r=>{r.preventDefault(),J(s.value.trim(),u,a,n)}),y(t,S(o),e("main",{class:"login"},p)),s.focus()}async function J(t,o,n,i){if(t!==""){o.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let a=await v("POST","/api/v1/webauthn/login/begin",{name:t});if(a.status!==200||a.body===null||b(a.body)){n.textContent="Connexion impossible pour le moment.";return}let s=await L(a.body.webauthn),u=await v("POST","/api/v1/webauthn/login/finish",{challenge_id:a.body.challenge_id,credential:s});if(u.status!==200||u.body===null||b(u.body)){n.textContent="Identifiants invalides.";return}i(u.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{o.disabled=!1}}}function E(t){let o=new Date(t);return Number.isNaN(o.getTime())?t:`${o.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var F={action_mismatch:"La signature ne correspond pas \xE0 cette action.",already_used:"Cette signature a d\xE9j\xE0 servi : relancez.",expired:"Le d\xE9lai de signature est d\xE9pass\xE9 : relancez.",denied:"Action refus\xE9e par l'autorit\xE9 (r\xF4le ou \xE9tat de la demande).",signature_rejected:"Signature refus\xE9e par l'autorit\xE9.",unauthenticated:"Session expir\xE9e : reconnectez-vous."};function O(t,o){return b(t)?F[t.error]??t.message:o}function C(t){return new Promise(o=>{let n=!1,i=!1,a=null,s=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),u=e("div",{class:"frozen","data-testid":"frozen"}),p=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),r=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),m=e("dl",{class:"summary"},...t.summary.flatMap(([c,g])=>[e("dt",{},c),e("dd",{},g)])),l=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),m,u,s,e("div",{class:"actions"},r,p)),f=c=>{l.close(),l.remove(),o(c)},h=async()=>{p.disabled=!0,s.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let c=await v("POST","/api/v1/webauthn/challenge",t.action);return c.status!==200||c.body===null||b(c.body)?(s.textContent=O(c.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(a=c.body,y(u,e("p",{class:"muted"},"Corps exact qui sera ex\xE9cut\xE9 (fig\xE9 par l'autorit\xE9) :"),e("pre",{class:"mono","data-testid":"frozen-body"},JSON.stringify(a.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(a.body_hash))),s.textContent="",p.disabled=!1,!0)},d=async()=>{if(a===null&&!await h())return;let c=a;if(c!==null){n=!0,p.disabled=!0,r.disabled=!0,s.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let g=await L(c.webauthn),w=await v("POST",t.route,{challenge_id:c.challenge_id,assertion:g});if(w.status===200&&w.body!==null&&!b(w.body)){i=!0;let _=w.body;s.textContent=_.status==="AWAITING_QUORUM"?"\u2713 Signature enregistr\xE9e.":"\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>f(_),800);return}a=null,s.textContent=O(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{s.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,i||(p.disabled=!1,r.disabled=!1)}}};l.addEventListener("cancel",c=>{c.preventDefault(),!n&&!i&&f(null)}),r.addEventListener("click",()=>{n||f(null)}),p.addEventListener("click",()=>{d()}),document.body.append(l),l.showModal(),h().then(()=>p.focus())})}var R=[[1,"keyCompromise \u2014 cl\xE9 priv\xE9e compromise"],[3,"affiliationChanged \u2014 rattachement modifi\xE9"],[4,"superseded \u2014 remplac\xE9"],[5,"cessationOfOperation \u2014 cessation d'activit\xE9"],[9,"privilegeWithdrawn \u2014 privil\xE8ge retir\xE9"]];function T(t){return(t.toUpperCase().match(/.{1,2}/g)??[]).join(":")}function D(t){let o=[],n=0,i=e("tbody",{}),a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"certificates-status"}),s=e("aside",{class:"inspector","aria-label":"Inspecteur"}),u=e("section",{},e("h1",{},"Certificats \xE9mis"),a,e("div",{class:"split"},e("table",{class:"dense","data-testid":"certificates"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Num\xE9ro de s\xE9rie"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"Expire le"))),i),s)),p=()=>{y(i,...o.map((h,d)=>{let c=e("tr",{"aria-selected":String(d===n),"data-testid":`cert-${h.serial_hex}`},e("td",{},e("span",{class:"badge valid"},"\u25CF actif")),e("td",{class:"mono"},T(h.serial_hex)),e("td",{},h.subject_dn),e("td",{},h.profile),e("td",{class:"mono"},h.not_after?E(h.not_after):"\u2014"));return c.addEventListener("click",()=>{n=d,p()}),c}));let l=o[n];if(l===void 0){y(s,e("p",{class:"muted"},"Aucun certificat actif."));return}let f=e("button",{type:"button",class:"danger","data-testid":"revoke"},"R\xE9voquer le certificat\u2026");f.addEventListener("click",()=>{r(l)}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(l.serial_hex)),e("dt",{},"Sujet"),e("dd",{},l.subject_dn),e("dt",{},"Profil"),e("dd",{},l.profile),e("dt",{},"Expire le"),e("dd",{class:"mono"},l.not_after?E(l.not_after):"\u2014")),e("p",{class:"muted"},"La r\xE9vocation exige la signature de deux op\xE9rateurs CA distincts."),e("div",{class:"actions"},f))},r=async l=>{let f=await K();if(f===null)return;let[h,d]=R.find(([g])=>g===f.reason)??[f.reason,String(f.reason)],c=await C({title:"R\xE9vocation de certificat",summary:[["Action","r\xE9voquer d\xE9finitivement le certificat"],["Num\xE9ro de s\xE9rie",T(l.serial_hex)],["Sujet",l.subject_dn],["Motif RFC 5280",`${d} (${h})`],["Justification",f.comment]],action:{action:"revoke_certificate",serial:l.serial_hex,reason:f.reason,comment:f.comment},route:`/api/v1/certificates/${l.serial_hex}/revoke`});c!==null&&(a.textContent=c.status==="AWAITING_QUORUM"?`Signature enregistr\xE9e (${String(c.signatures)} sur ${String(c.required)}) : en attente d'un second op\xE9rateur CA, voir la salle de quorum.`:`Certificat ${T(l.serial_hex)} r\xE9voqu\xE9.`,await m(),t())},m=async()=>{let l=await v("GET","/api/v1/certificates?status=issued");o=Array.isArray(l.body)?l.body:[],b(l.body)&&(a.textContent=l.body.message),n=Math.min(n,Math.max(o.length-1,0)),p()};return m(),{element:u,dispose:()=>{}}}function K(){return new Promise(t=>{let o=e("select",{id:"revocation-reason",required:"","data-testid":"reason"},e("option",{value:""},"\u2014 choisir un motif \u2014"),...R.map(([r,m])=>e("option",{value:String(r)},m))),n=e("textarea",{id:"revocation-comment",rows:"3",maxlength:"1000",required:"","data-testid":"comment"}),i=e("button",{type:"submit",class:"danger","data-testid":"comment-next"},"Pr\xE9parer la signature"),a=e("button",{type:"button"},"Annuler"),s=e("form",{method:"dialog"},e("h2",{},"R\xE9vocation : motif et justification"),e("label",{for:"revocation-reason"},"Motif (RFC 5280)"),o,e("label",{for:"revocation-comment"},"Justification consign\xE9e au journal (obligatoire)"),n,e("div",{class:"actions"},a,i)),u=e("dialog",{class:"signature","data-testid":"revocation-dialog"},s),p=r=>{u.close(),u.remove(),t(r)};s.addEventListener("submit",r=>{r.preventDefault();let m=Number(o.value),l=n.value.trim();!R.some(([f])=>f===m)||l===""||p({reason:m,comment:l})}),a.addEventListener("click",()=>p(null)),u.addEventListener("cancel",r=>{r.preventDefault(),p(null)}),document.body.append(u),u.showModal(),o.focus()})}var q={revoke_certificate:"R\xE9vocation de certificat",set_role:"Changement de r\xF4le",invite_operator:"Invitation d'un op\xE9rateur"};function $(t,o){let n=e("div",{class:"quorum","data-testid":"quorum"}),i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"quorum-status"}),a=e("section",{},e("h1",{},"Salle de quorum"),i,n),s=r=>{let m=r.signed_by.includes(t.operator),l=e("button",{type:"button",class:"primary","data-testid":`cosign-${r.action_id}`,...m?{disabled:""}:{}},"Co-signer avec ma cl\xE9 FIDO2");l.addEventListener("click",()=>{u(r)});let f=typeof r.body.serial=="string"?r.body.serial:null;return e("article",{class:"card","data-testid":`pending-${r.action_id}`},e("h2",{},q[r.action]??r.action),e("dl",{},e("dt",{},"Signatures"),e("dd",{"data-testid":`progress-${r.action_id}`},`${r.signatures} sur ${r.required}`),e("dt",{},"D\xE9j\xE0 sign\xE9 par"),e("dd",{},r.signed_by.join(", ")||"\u2014"),...f?[e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},T(f))]:[],e("dt",{},"Initi\xE9e le"),e("dd",{class:"mono"},E(r.created_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},E(r.expires_at)),e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},I(r.body_hash))),e("pre",{class:"mono"},JSON.stringify(r.body,null,2)),m?e("p",{class:"muted","data-testid":`own-${r.action_id}`},"Le double contr\xF4le requiert un op\xE9rateur distinct : vous avez d\xE9j\xE0 sign\xE9."):null,e("div",{class:"actions"},l))},u=async r=>{let m=await C({title:`Co-signature : ${q[r.action]??r.action}`,summary:[["Action",q[r.action]??r.action],["D\xE9j\xE0 sign\xE9 par",r.signed_by.join(", ")],["Signatures",`${r.signatures+1} sur ${r.required} apr\xE8s la v\xF4tre`]],action:{action_id:r.action_id},route:`/api/v1/quorum/${r.action_id}/sign`});m!==null&&(i.textContent=m.status==="EXECUTED"?"Action ex\xE9cut\xE9e par l'autorit\xE9.":"Signature enregistr\xE9e.",await p(),o())},p=async()=>{let r=await v("GET","/api/v1/quorum?state=PENDING"),m=Array.isArray(r.body)?r.body:[];b(r.body)&&(i.textContent=r.body.message),y(n,...m.length===0?[e("p",{class:"muted"},"Aucune action en attente de signature.")]:m.map(s))};return p(),{element:a,dispose:()=>{}}}function V(t){let o=[],n=0,i=e("tbody",{}),a=e("table",{class:"dense","aria-label":"Demandes en attente","data-testid":"requests"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Transaction"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"D\xE9pos\xE9e le"))),i),s=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),u=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),p=e("section",{class:"requests"},e("h1",{},"Demandes d'enr\xF4lement en attente"),e("p",{class:"muted"},"j/k : se d\xE9placer \xB7 a : approuver \xB7 r : rejeter"),u,e("div",{class:"split"},a,s)),r=()=>{y(i,...o.map((d,c)=>{let g=e("tr",{"aria-selected":String(c===n),"data-testid":`row-${d.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},d.transaction_id),e("td",{},d.subject_cn),e("td",{},d.profile),e("td",{class:"mono"},E(d.created_at)));return g.addEventListener("click",()=>{n=c,r()}),g})),o.length===0&&y(i,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),m()},m=()=>{let d=o[n];if(d===void 0){y(s,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let c=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),g=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");c.addEventListener("click",()=>{l(d,"approve")}),g.addEventListener("click",()=>{l(d,"reject")}),y(s,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},d.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},d.subject_cn),e("dt",{},"Profil"),e("dd",{},d.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},E(d.created_at))),e("div",{class:"actions"},c,g))},l=async(d,c)=>{let g=await W(c);if(g===null)return;let w=c==="approve";await C({title:w?`Approbation de la demande ${d.transaction_id}`:`Rejet de la demande ${d.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",d.transaction_id],["Sujet (CN)",d.subject_cn],["Profil",d.profile],["Justification",g===""?"\u2014":g]],action:{action:w?"approve_request":"reject_request",transaction_id:d.transaction_id,comment:g},route:`/api/v1/requests/${encodeURIComponent(d.transaction_id)}/${c}`})!==null&&(u.textContent=w?`Demande ${d.transaction_id} approuv\xE9e.`:`Demande ${d.transaction_id} rejet\xE9e.`,await f(),t())},f=async()=>{let d=await v("GET","/api/v1/requests?state=PENDING");d.status!==200||!Array.isArray(d.body)?(u.textContent=b(d.body)?d.body.message:"File indisponible.",o=[]):o=d.body,n=Math.min(n,Math.max(o.length-1,0)),r()},h=d=>{let c=d.target;if(document.querySelector("dialog[open]")!==null||c!==null&&["INPUT","TEXTAREA","SELECT"].includes(c.tagName))return;let g=o[n];if(d.key==="j"||d.key==="ArrowDown")n=Math.min(n+1,o.length-1),r();else if(d.key==="k"||d.key==="ArrowUp")n=Math.max(n-1,0),r();else if(d.key==="a"&&g!==void 0)l(g,"approve");else if(d.key==="r"&&g!==void 0)l(g,"reject");else return;d.preventDefault()};return window.addEventListener("keydown",h),f(),{element:p,dispose:()=>window.removeEventListener("keydown",h)}}function W(t){return new Promise(o=>{let n=t==="reject",i=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),a=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),s=e("button",{type:"button"},"Annuler"),u=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),i,e("div",{class:"actions"},s,a)),p=e("dialog",{class:"signature","data-testid":"comment-dialog"},u),r=m=>{p.close(),p.remove(),o(m)};u.addEventListener("submit",m=>{m.preventDefault();let l=i.value.trim();if(n&&l===""){i.focus();return}r(l)}),s.addEventListener("click",()=>r(null)),p.addEventListener("cancel",m=>{m.preventDefault(),r(null)}),document.body.append(p),p.showModal(),i.focus()})}var Q={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function H(t,o,n,i){let a=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");a.addEventListener("click",i);let s=e("header",{class:"security-bar"},e("span",{class:"brand"},"Open eIDAS"),e("span",{class:"identity"},e("span",{class:"operator","data-testid":"operator"},n.operator),e("span",{class:`role role-${n.role}`,"data-testid":"role"},Q[n.role])),a),u=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),p=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),r=()=>{X(u,p)},m=e("main",{class:"workspace",tabindex:"-1"}),l=null,f=[["requests","Demandes RA ",u,()=>V(r)],["certificates","Certificats",null,()=>D(r)],["quorum","Quorum ",p,()=>$(n,r)]],h=f.map(([w,_,A,B])=>{let P=e("button",{type:"button",class:"nav","data-testid":`nav-${w}`},_,A);return P.addEventListener("click",()=>d(w,B)),P}),d=(w,_)=>{l?.dispose(),l=_(),y(m,l.element);for(let A of h)A.setAttribute("aria-current",String(A.dataset.testid===`nav-${w}`))},c=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},...h.map(w=>e("li",{},w))));y(t,S(o),s,e("div",{class:"layout"},c,m));let g=f[0];return d(g[0],g[3]),r(),()=>l?.dispose()}async function X(t,o){let[n,i]=await Promise.all([v("GET","/api/v1/requests?state=PENDING"),v("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",o.textContent=Array.isArray(i.body)?`(${i.body.length})`:"(\u2014)",(b(n.body)||b(i.body))&&(t.title=o.title="compteur indisponible")}function G(t){let o=e("div",{class:"idle-warning",role:"alert","data-testid":"idle-warning"},"Session inactive : verrouillage dans une minute. Une action au clavier ou \xE0 la souris la prolonge.");return t.prepend(o),o}async function Y(t){let o=await v("GET","/api/v1/console"),n=o.body!==null&&!b(o.body)?o.body:{environment:"undeclared",version:"?"},i=null,a=null,s=m=>{i?.(),i=null,a?.(),a=null,j(t,n,p,m)},u=async m=>{await v("POST","/api/v1/logout"),s(m)},p=m=>{a=H(t,n,m,()=>{u()});let l=null;i=N(()=>{l??=G(t)},()=>{u("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let f=()=>{l?.remove(),l=null};window.addEventListener("keydown",f),window.addEventListener("pointerdown",f)},r=await v("GET","/api/v1/me");r.status===200&&r.body!==null&&!b(r.body)?p(r.body):s()}var U=document.getElementById("app");U!==null&&Y(U); +async function w(t,i,n){let a={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(a.headers={"Content-Type":"application/json"},a.body=JSON.stringify(n));let o=await fetch(i,a),l=await o.text(),u=null;if(l!=="")try{u=JSON.parse(l)}catch{u=null}return{status:o.status,body:u}}function h(t){return typeof t=="object"&&t!==null&&"error"in t}var V=["keydown","pointerdown","wheel","touchstart"];function U(t,i){let n=0,a=0,o=()=>{window.clearTimeout(n),window.clearTimeout(a),n=window.setTimeout(t,84e4),a=window.setTimeout(i,9e5)};for(let l of V)window.addEventListener(l,o,{passive:!0});return o(),()=>{window.clearTimeout(n),window.clearTimeout(a);for(let l of V)window.removeEventListener(l,o)}}function e(t,i={},...n){let a=document.createElement(t);for(let[o,l]of Object.entries(i))a.setAttribute(o,l);for(let o of n)o==null||o===!1||a.append(typeof o=="string"?document.createTextNode(o):o);return a}function b(t,...i){t.replaceChildren(...i)}var Y={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function P(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},Y[t.environment])}function R(t){let i=new Uint8Array(t),n="";for(let a of i)n+=String.fromCharCode(a);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function $(t){let i=t.replace(/-/g,"+").replace(/_/g,"/"),n=i+"=".repeat((4-i.length%4)%4),a=atob(n),o=new Uint8Array(a.length);for(let l=0;l({type:o.type,id:$(o.id),...o.transports?{transports:o.transports}:{}})),...t.timeout!==void 0?{timeout:t.timeout}:{},...t.rpId!==void 0?{rpId:t.rpId}:{},...t.userVerification!==void 0?{userVerification:t.userVerification}:{}},n=await navigator.credentials.get({publicKey:i});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let a=n.response;return{id:n.id,rawId:R(n.rawId),type:n.type,response:{clientDataJSON:R(a.clientDataJSON),authenticatorData:R(a.authenticatorData),signature:R(a.signature),userHandle:a.userHandle?R(a.userHandle):null},extensions:{}}}function H(t,i,n,a){let o=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},a??""),l=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),u=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),f=e("form",{class:"login-form","aria-labelledby":"login-title"},e("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),e("label",{for:"operator-name"},"Nom d'op\xE9rateur"),l,u,o);f.addEventListener("submit",r=>{r.preventDefault(),Z(l.value.trim(),u,o,n)}),b(t,P(i),e("main",{class:"login"},f)),l.focus()}async function Z(t,i,n,a){if(t!==""){i.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let o=await w("POST","/api/v1/webauthn/login/begin",{name:t});if(o.status!==200||o.body===null||h(o.body)){n.textContent="Connexion impossible pour le moment.";return}let l=await I(o.body.webauthn),u=await w("POST","/api/v1/webauthn/login/finish",{challenge_id:o.body.challenge_id,credential:l});if(u.status!==200||u.body===null||h(u.body)){n.textContent="Identifiants invalides.";return}a(u.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{i.disabled=!1}}}function x(t){let i=new Date(t);return Number.isNaN(i.getTime())?t:`${i.toISOString().slice(0,19).replace("T"," ")} UTC`}function q(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var ee={action_mismatch:"La signature ne correspond pas \xE0 cette action.",already_used:"Cette signature a d\xE9j\xE0 servi : relancez.",expired:"Le d\xE9lai de signature est d\xE9pass\xE9 : relancez.",denied:"Action refus\xE9e par l'autorit\xE9 (r\xF4le ou \xE9tat de la demande).",signature_rejected:"Signature refus\xE9e par l'autorit\xE9.",unauthenticated:"Session expir\xE9e : reconnectez-vous."};function G(t,i){return h(t)?ee[t.error]??t.message:i}function A(t){return new Promise(i=>{let n=!1,a=!1,o=null,l=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),u=e("div",{class:"frozen","data-testid":"frozen"}),f=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),r=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),m=e("dl",{class:"summary"},...t.summary.flatMap(([p,y])=>[e("dt",{},p),e("dd",{},y)])),d=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),m,u,l,e("div",{class:"actions"},r,f)),g=p=>{d.close(),d.remove(),i(p)},_=async()=>{f.disabled=!0,l.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let p=await w("POST","/api/v1/webauthn/challenge",t.action);return p.status!==200||p.body===null||h(p.body)?(l.textContent=G(p.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(o=p.body,b(u,e("p",{class:"muted"},"Corps exact qui sera ex\xE9cut\xE9 (fig\xE9 par l'autorit\xE9) :"),e("pre",{class:"mono","data-testid":"frozen-body"},JSON.stringify(o.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},q(o.body_hash))),l.textContent="",f.disabled=!1,!0)},c=async()=>{if(o===null&&!await _())return;let p=o;if(p!==null){n=!0,f.disabled=!0,r.disabled=!0,l.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let y=await I(p.webauthn),E=await w("POST",t.route,{challenge_id:p.challenge_id,assertion:y});if(E.status===200&&E.body!==null&&!h(E.body)){a=!0;let T=E.body;l.textContent=T.status==="AWAITING_QUORUM"?"\u2713 Signature enregistr\xE9e.":"\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>g(T),800);return}o=null,l.textContent=G(E.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{l.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,a||(f.disabled=!1,r.disabled=!1)}}};d.addEventListener("cancel",p=>{p.preventDefault(),!n&&!a&&g(null)}),r.addEventListener("click",()=>{n||g(null)}),f.addEventListener("click",()=>{c()}),document.body.append(d),d.showModal(),_().then(()=>f.focus())})}var O=[[1,"keyCompromise \u2014 cl\xE9 priv\xE9e compromise"],[3,"affiliationChanged \u2014 rattachement modifi\xE9"],[4,"superseded \u2014 remplac\xE9"],[5,"cessationOfOperation \u2014 cessation d'activit\xE9"],[9,"privilegeWithdrawn \u2014 privil\xE8ge retir\xE9"]];function S(t){return(t.toUpperCase().match(/.{1,2}/g)??[]).join(":")}function K(t){let i=[],n=0,a=e("tbody",{}),o=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"certificates-status"}),l=e("aside",{class:"inspector","aria-label":"Inspecteur"}),u=e("section",{},e("h1",{},"Certificats \xE9mis"),o,e("div",{class:"split"},e("table",{class:"dense","data-testid":"certificates"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Num\xE9ro de s\xE9rie"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"Expire le"))),a),l)),f=()=>{b(a,...i.map((_,c)=>{let p=e("tr",{"aria-selected":String(c===n),"data-testid":`cert-${_.serial_hex}`},e("td",{},e("span",{class:"badge valid"},"\u25CF actif")),e("td",{class:"mono"},S(_.serial_hex)),e("td",{},_.subject_dn),e("td",{},_.profile),e("td",{class:"mono"},_.not_after?x(_.not_after):"\u2014"));return p.addEventListener("click",()=>{n=c,f()}),p}));let d=i[n];if(d===void 0){b(l,e("p",{class:"muted"},"Aucun certificat actif."));return}let g=e("button",{type:"button",class:"danger","data-testid":"revoke"},"R\xE9voquer le certificat\u2026");g.addEventListener("click",()=>{r(d)}),b(l,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},S(d.serial_hex)),e("dt",{},"Sujet"),e("dd",{},d.subject_dn),e("dt",{},"Profil"),e("dd",{},d.profile),e("dt",{},"Expire le"),e("dd",{class:"mono"},d.not_after?x(d.not_after):"\u2014")),e("p",{class:"muted"},"La r\xE9vocation exige la signature de deux op\xE9rateurs CA distincts."),e("div",{class:"actions"},g))},r=async d=>{let g=await te();if(g===null)return;let[_,c]=O.find(([y])=>y===g.reason)??[g.reason,String(g.reason)],p=await A({title:"R\xE9vocation de certificat",summary:[["Action","r\xE9voquer d\xE9finitivement le certificat"],["Num\xE9ro de s\xE9rie",S(d.serial_hex)],["Sujet",d.subject_dn],["Motif RFC 5280",`${c} (${_})`],["Justification",g.comment]],action:{action:"revoke_certificate",serial:d.serial_hex,reason:g.reason,comment:g.comment},route:`/api/v1/certificates/${d.serial_hex}/revoke`});p!==null&&(o.textContent=p.status==="AWAITING_QUORUM"?`Signature enregistr\xE9e (${String(p.signatures)} sur ${String(p.required)}) : en attente d'un second op\xE9rateur CA, voir la salle de quorum.`:`Certificat ${S(d.serial_hex)} r\xE9voqu\xE9.`,await m(),t())},m=async()=>{let d=await w("GET","/api/v1/certificates?status=issued");i=Array.isArray(d.body)?d.body:[],h(d.body)&&(o.textContent=d.body.message),n=Math.min(n,Math.max(i.length-1,0)),f()};return m(),{element:u,dispose:()=>{}}}function te(){return new Promise(t=>{let i=e("select",{id:"revocation-reason",required:"","data-testid":"reason"},e("option",{value:""},"\u2014 choisir un motif \u2014"),...O.map(([r,m])=>e("option",{value:String(r)},m))),n=e("textarea",{id:"revocation-comment",rows:"3",maxlength:"1000",required:"","data-testid":"comment"}),a=e("button",{type:"submit",class:"danger","data-testid":"comment-next"},"Pr\xE9parer la signature"),o=e("button",{type:"button"},"Annuler"),l=e("form",{method:"dialog"},e("h2",{},"R\xE9vocation : motif et justification"),e("label",{for:"revocation-reason"},"Motif (RFC 5280)"),i,e("label",{for:"revocation-comment"},"Justification consign\xE9e au journal (obligatoire)"),n,e("div",{class:"actions"},o,a)),u=e("dialog",{class:"signature","data-testid":"revocation-dialog"},l),f=r=>{u.close(),u.remove(),t(r)};l.addEventListener("submit",r=>{r.preventDefault();let m=Number(i.value),d=n.value.trim();!O.some(([g])=>g===m)||d===""||f({reason:m,comment:d})}),o.addEventListener("click",()=>f(null)),u.addEventListener("cancel",r=>{r.preventDefault(),f(null)}),document.body.append(u),u.showModal(),i.focus()})}var z=[["auditeur","auditeur"],["ra_operateur","op\xE9rateur RA"],["ca_operateur","op\xE9rateur CA"],["admin","administrateur"]];function J(t){let i=t.role==="admin",n=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"operators-status"}),a=e("tbody",{}),o=e("aside",{class:"inspector","aria-label":"Inspecteur"}),l=e("div",{class:"quorum","data-testid":"pending-keys"}),u=e("button",{type:"button",class:"primary","data-testid":"invite",...i?{}:{disabled:""}},"Inviter un op\xE9rateur\u2026"),f=e("section",{},e("h1",{},"Op\xE9rateurs"),i?null:e("p",{class:"muted"},"Consultation seule : la gestion du registre est r\xE9serv\xE9e aux administrateurs."),n,e("div",{class:"actions start"},u),e("div",{class:"split"},e("table",{class:"dense","data-testid":"operators"},e("thead",{},e("tr",{},e("th",{},"Op\xE9rateur"),e("th",{},"R\xF4le"),e("th",{},"Cl\xE9s actives"),e("th",{},"\xC9tat"))),a),o),e("h2",{},"Cl\xE9s en attente de confirmation"),l),r={operators:[],pending:[]},m=0,d=s=>s.credentials.filter(v=>v.revoked_at===null),g=()=>{b(a,...r.operators.map((s,v)=>{let C=e("tr",{"aria-selected":String(v===m),"data-testid":`operator-${s.name}`},e("td",{},s.name),e("td",{},e("span",{class:`role role-${s.role}`},s.role)),e("td",{class:"mono"},String(d(s).length)),e("td",{},s.disabled?"d\xE9sactiv\xE9":"actif"));return C.addEventListener("click",()=>{m=v,g()}),C})),_(),b(l,...r.pending.length===0?[e("p",{class:"muted"},"Aucune cl\xE9 en attente.")]:r.pending.map(s=>{let v=e("button",{type:"button",class:"primary","data-testid":`confirm-${s.operator}`,...i&&s.key_fingerprint?{}:{disabled:""}},"Confirmer la cl\xE9\u2026");return v.addEventListener("click",()=>{T(s)}),e("article",{class:"card"},e("h2",{},`Cl\xE9 de ${s.operator}`),e("dl",{},e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},s.key_fingerprint??"illisible"),e("dt",{},"Enregistr\xE9e le"),e("dd",{class:"mono"},x(s.registered_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},x(s.expires_at))),e("div",{class:"actions"},v))}))},_=()=>{let s=r.operators[m];if(s===void 0){b(o,e("p",{class:"muted"},"Aucun op\xE9rateur."));return}let v=e("select",{id:"new-role","data-testid":"new-role",...i?{}:{disabled:""}},...z.map(([k,M])=>{let D=e("option",{value:k},M);return k===s.role&&(D.selected=!0),D})),C=e("button",{type:"button","data-testid":"change-role",...i?{}:{disabled:""}},"Changer le r\xF4le\u2026");C.addEventListener("click",()=>{y(s,v.value)}),b(o,e("h2",{},s.name),e("label",{for:"new-role"},"R\xF4le"),v,e("div",{class:"actions"},C),e("h2",{},"Cl\xE9s"),...s.credentials.map(k=>{let M=e("button",{type:"button",class:"danger","data-testid":`revoke-key-${s.name}`,...i&&k.revoked_at===null?{}:{disabled:""}},"R\xE9voquer\u2026");return M.addEventListener("click",()=>{E(s,k)}),e("div",{class:"key"},e("p",{class:"mono"},`${k.label} \xB7 ${k.credential_id.slice(0,16)}\u2026`),e("p",{class:"muted"},k.revoked_at?`r\xE9voqu\xE9e le ${x(k.revoked_at)}`:`active depuis le ${x(k.initiated_at)}`),M)}))},c=async()=>{let s=await w("GET","/api/v1/operators");s.status===200&&s.body!==null&&!h(s.body)?r=s.body:h(s.body)&&(n.textContent=s.body.message),m=Math.min(m,Math.max(r.operators.length-1,0)),g()},p=async(s,v)=>{s!==null&&(n.textContent=s.status==="AWAITING_QUORUM"?"Signature enregistr\xE9e : un second administrateur doit co-signer (salle de quorum).":v,await c())},y=async(s,v)=>{if(v===s.role)return;let C=await A({title:`Changement de r\xF4le de ${s.name}`,summary:[["Op\xE9rateur",s.name],["R\xF4le actuel",s.role],["Nouveau r\xF4le",v]],action:{action:"set_role",operator:s.name,role:v},route:`/api/v1/operators/${encodeURIComponent(s.name)}/role`});await p(C,`R\xF4le de ${s.name} : ${v}.`)},E=async(s,v)=>{let C=await re("Motif de la r\xE9vocation de la cl\xE9 (obligatoire)","revoke-reason");if(C===null)return;let k=await A({title:`R\xE9vocation d'une cl\xE9 de ${s.name}`,summary:[["Op\xE9rateur",s.name],["Cl\xE9",`${v.label} \xB7 ${v.credential_id}`],["Motif",C]],action:{action:"revoke_key",credential_id:v.credential_id,reason:C},route:`/api/v1/credentials/${encodeURIComponent(v.credential_id)}/revoke`});await p(k,`Cl\xE9 de ${s.name} r\xE9voqu\xE9e.`)},T=async s=>{if(s.key_fingerprint===null||!await oe(s))return;let C=await A({title:`Confirmation de la cl\xE9 de ${s.operator}`,summary:[["Op\xE9rateur",s.operator],["Empreinte compar\xE9e",s.key_fingerprint]],action:{action:"confirm_key",credential_id:s.credential_id,key_fingerprint:s.key_fingerprint},route:`/api/v1/credentials/${encodeURIComponent(s.credential_id)}/confirm`});await p(C,`Cl\xE9 de ${s.operator} confirm\xE9e.`)};u.addEventListener("click",()=>{L()});let L=async()=>{let s=await ie();if(s===null)return;let v=await A({title:`Invitation de ${s.name}`,summary:[["Op\xE9rateur invit\xE9",s.name],["R\xF4le",s.role]],action:{action:"invite_operator",name:s.name,role:s.role},route:"/api/v1/operators"});if(v===null)return;let C=v.result;v.status==="EXECUTED"&&typeof C?.invite_token=="string"&&await ne(s.name,C.invite_token),await p(v,`Invitation de ${s.name} cr\xE9\xE9e.`)};return c(),{element:f,dispose:()=>{}}}function ne(t,i){return new Promise(n=>{let a=e("button",{type:"button",class:"primary","data-testid":"token-close"},"J'ai transmis le jeton"),o=e("dialog",{class:"signature","data-testid":"token-dialog"},e("h2",{},`Jeton d'invitation de ${t}`),e("p",{},"Affich\xE9 une seule fois. Transmettez-le \xE0 l'invit\xE9 par un canal distinct ; il lui sert \xE0 enregistrer sa cl\xE9 FIDO2."),e("pre",{class:"mono","data-testid":"invite-token"},i),e("div",{class:"actions"},a)),l=()=>{o.close(),o.remove(),n()};a.addEventListener("click",l),o.addEventListener("cancel",u=>{u.preventDefault(),l()}),document.body.append(o),o.showModal()})}function N(t,i,n,a){return new Promise(o=>{let l=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),u=e("button",{type:"button"},"Annuler"),f=e("form",{method:"dialog"},e("h2",{},i),...n,e("div",{class:"actions"},u,l)),r=e("dialog",{class:"signature","data-testid":t},f),m=d=>{r.close(),r.remove(),o(d)};f.addEventListener("submit",d=>{d.preventDefault(),a()&&m(!0)}),u.addEventListener("click",()=>m(!1)),r.addEventListener("cancel",d=>{d.preventDefault(),m(!1)}),document.body.append(r),r.showModal()})}async function re(t,i){let n=e("textarea",{rows:"3",maxlength:"1000",required:"","data-testid":"comment"});return await N(i,t,[n],()=>n.value.trim()!=="")?n.value.trim():null}async function ie(){let t=e("input",{id:"invite-name",required:"",maxlength:"100","data-testid":"invite-name"}),i=e("select",{id:"invite-role","data-testid":"invite-role"},...z.map(([a,o])=>e("option",{value:a},o)));return await N("invite-dialog","Inviter un op\xE9rateur",[e("label",{for:"invite-name"},"Nom"),t,e("label",{for:"invite-role"},"R\xF4le"),i],()=>t.value.trim()!=="")?{name:t.value.trim(),role:i.value}:null}async function oe(t){let i=e("input",{type:"checkbox",id:"compared",required:"","data-testid":"compared"});return N("confirm-dialog",`Confirmer la cl\xE9 de ${t.operator}`,[e("p",{},"Comparez cette empreinte, hors bande, avec celle que l'invit\xE9 a obtenue en enregistrant sa cl\xE9 :"),e("p",{class:"mono hash"},t.key_fingerprint??""),e("label",{for:"compared"},i," J'ai compar\xE9 l'empreinte avec l'invit\xE9 : elle est identique.")],()=>i.checked)}var j={revoke_certificate:"R\xE9vocation de certificat",set_role:"Changement de r\xF4le",invite_operator:"Invitation d'un op\xE9rateur"};function B(t,i){let n=e("div",{class:"quorum","data-testid":"quorum"}),a=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"quorum-status"}),o=e("section",{},e("h1",{},"Salle de quorum"),a,n),l=r=>{let m=r.signed_by.includes(t.operator),d=e("button",{type:"button",class:"primary","data-testid":`cosign-${r.action_id}`,...m?{disabled:""}:{}},"Co-signer avec ma cl\xE9 FIDO2");d.addEventListener("click",()=>{u(r)});let g=typeof r.body.serial=="string"?r.body.serial:null;return e("article",{class:"card","data-testid":`pending-${r.action_id}`},e("h2",{},j[r.action]??r.action),e("dl",{},e("dt",{},"Signatures"),e("dd",{"data-testid":`progress-${r.action_id}`},`${r.signatures} sur ${r.required}`),e("dt",{},"D\xE9j\xE0 sign\xE9 par"),e("dd",{},r.signed_by.join(", ")||"\u2014"),...g?[e("dt",{},"Num\xE9ro de s\xE9rie"),e("dd",{class:"mono"},S(g))]:[],e("dt",{},"Initi\xE9e le"),e("dd",{class:"mono"},x(r.created_at)),e("dt",{},"Expire le"),e("dd",{class:"mono"},x(r.expires_at)),e("dt",{},"Empreinte"),e("dd",{class:"mono hash"},q(r.body_hash))),e("pre",{class:"mono"},JSON.stringify(r.body,null,2)),m?e("p",{class:"muted","data-testid":`own-${r.action_id}`},"Le double contr\xF4le requiert un op\xE9rateur distinct : vous avez d\xE9j\xE0 sign\xE9."):null,e("div",{class:"actions"},d))},u=async r=>{let m=await A({title:`Co-signature : ${j[r.action]??r.action}`,summary:[["Action",j[r.action]??r.action],["D\xE9j\xE0 sign\xE9 par",r.signed_by.join(", ")],["Signatures",`${r.signatures+1} sur ${r.required} apr\xE8s la v\xF4tre`]],action:{action_id:r.action_id},route:`/api/v1/quorum/${r.action_id}/sign`});m!==null&&(a.textContent=m.status==="EXECUTED"?"Action ex\xE9cut\xE9e par l'autorit\xE9.":"Signature enregistr\xE9e.",await f(),i())},f=async()=>{let r=await w("GET","/api/v1/quorum?state=PENDING"),m=Array.isArray(r.body)?r.body:[];h(r.body)&&(a.textContent=r.body.message),b(n,...m.length===0?[e("p",{class:"muted"},"Aucune action en attente de signature.")]:m.map(l))};return f(),{element:o,dispose:()=>{}}}function F(t){let i=[],n=0,a=e("tbody",{}),o=e("table",{class:"dense","aria-label":"Demandes en attente","data-testid":"requests"},e("thead",{},e("tr",{},e("th",{},"Statut"),e("th",{},"Transaction"),e("th",{},"Sujet"),e("th",{},"Profil"),e("th",{},"D\xE9pos\xE9e le"))),a),l=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),u=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),f=e("section",{class:"requests"},e("h1",{},"Demandes d'enr\xF4lement en attente"),e("p",{class:"muted"},"j/k : se d\xE9placer \xB7 a : approuver \xB7 r : rejeter"),u,e("div",{class:"split"},o,l)),r=()=>{b(a,...i.map((c,p)=>{let y=e("tr",{"aria-selected":String(p===n),"data-testid":`row-${c.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},c.transaction_id),e("td",{},c.subject_cn),e("td",{},c.profile),e("td",{class:"mono"},x(c.created_at)));return y.addEventListener("click",()=>{n=p,r()}),y})),i.length===0&&b(a,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),m()},m=()=>{let c=i[n];if(c===void 0){b(l,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let p=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),y=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");p.addEventListener("click",()=>{d(c,"approve")}),y.addEventListener("click",()=>{d(c,"reject")}),b(l,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},c.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},c.subject_cn),e("dt",{},"Profil"),e("dd",{},c.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},x(c.created_at))),e("div",{class:"actions"},p,y))},d=async(c,p)=>{let y=await ae(p);if(y===null)return;let E=p==="approve";await A({title:E?`Approbation de la demande ${c.transaction_id}`:`Rejet de la demande ${c.transaction_id}`,summary:[["Action",E?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",c.transaction_id],["Sujet (CN)",c.subject_cn],["Profil",c.profile],["Justification",y===""?"\u2014":y]],action:{action:E?"approve_request":"reject_request",transaction_id:c.transaction_id,comment:y},route:`/api/v1/requests/${encodeURIComponent(c.transaction_id)}/${p}`})!==null&&(u.textContent=E?`Demande ${c.transaction_id} approuv\xE9e.`:`Demande ${c.transaction_id} rejet\xE9e.`,await g(),t())},g=async()=>{let c=await w("GET","/api/v1/requests?state=PENDING");c.status!==200||!Array.isArray(c.body)?(u.textContent=h(c.body)?c.body.message:"File indisponible.",i=[]):i=c.body,n=Math.min(n,Math.max(i.length-1,0)),r()},_=c=>{let p=c.target;if(document.querySelector("dialog[open]")!==null||p!==null&&["INPUT","TEXTAREA","SELECT"].includes(p.tagName))return;let y=i[n];if(c.key==="j"||c.key==="ArrowDown")n=Math.min(n+1,i.length-1),r();else if(c.key==="k"||c.key==="ArrowUp")n=Math.max(n-1,0),r();else if(c.key==="a"&&y!==void 0)d(y,"approve");else if(c.key==="r"&&y!==void 0)d(y,"reject");else return;c.preventDefault()};return window.addEventListener("keydown",_),g(),{element:f,dispose:()=>window.removeEventListener("keydown",_)}}function ae(t){return new Promise(i=>{let n=t==="reject",a=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),o=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),l=e("button",{type:"button"},"Annuler"),u=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),a,e("div",{class:"actions"},l,o)),f=e("dialog",{class:"signature","data-testid":"comment-dialog"},u),r=m=>{f.close(),f.remove(),i(m)};u.addEventListener("submit",m=>{m.preventDefault();let d=a.value.trim();if(n&&d===""){a.focus();return}r(d)}),l.addEventListener("click",()=>r(null)),f.addEventListener("cancel",m=>{m.preventDefault(),r(null)}),document.body.append(f),f.showModal(),a.focus()})}var se={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function W(t,i,n,a){let o=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");o.addEventListener("click",a);let l=e("header",{class:"security-bar"},e("span",{class:"brand"},"Open eIDAS"),e("span",{class:"identity"},e("span",{class:"operator","data-testid":"operator"},n.operator),e("span",{class:`role role-${n.role}`,"data-testid":"role"},se[n.role])),o),u=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),f=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),r=()=>{le(u,f)},m=e("main",{class:"workspace",tabindex:"-1"}),d=null,g=[["requests","Demandes RA ",u,()=>F(r)],["certificates","Certificats",null,()=>K(r)],["quorum","Quorum ",f,()=>B(n,r)],["operators","Op\xE9rateurs",null,()=>J(n)]],_=g.map(([E,T,L,s])=>{let v=e("button",{type:"button",class:"nav","data-testid":`nav-${E}`},T,L);return v.addEventListener("click",()=>c(E,s)),v}),c=(E,T)=>{d?.dispose(),d=T(),b(m,d.element);for(let L of _)L.setAttribute("aria-current",String(L.dataset.testid===`nav-${E}`))},p=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},..._.map(E=>e("li",{},E))));b(t,P(i),l,e("div",{class:"layout"},p,m));let y=g[0];return c(y[0],y[3]),r(),()=>d?.dispose()}async function le(t,i){let[n,a]=await Promise.all([w("GET","/api/v1/requests?state=PENDING"),w("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",i.textContent=Array.isArray(a.body)?`(${a.body.length})`:"(\u2014)",(h(n.body)||h(a.body))&&(t.title=i.title="compteur indisponible")}function Q(t){let i=e("div",{class:"idle-warning",role:"alert","data-testid":"idle-warning"},"Session inactive : verrouillage dans une minute. Une action au clavier ou \xE0 la souris la prolonge.");return t.prepend(i),i}async function de(t){let i=await w("GET","/api/v1/console"),n=i.body!==null&&!h(i.body)?i.body:{environment:"undeclared",version:"?"},a=null,o=null,l=m=>{a?.(),a=null,o?.(),o=null,H(t,n,f,m)},u=async m=>{await w("POST","/api/v1/logout"),l(m)},f=m=>{o=W(t,n,m,()=>{u()});let d=null;a=U(()=>{d??=Q(t)},()=>{u("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let g=()=>{d?.remove(),d=null};window.addEventListener("keydown",g),window.addEventListener("pointerdown",g)},r=await w("GET","/api/v1/me");r.status===200&&r.body!==null&&!h(r.body)?f(r.body):l()}var X=document.getElementById("app");X!==null&&de(X); diff --git a/bin/ra-console/web/e2e/operators.spec.ts b/bin/ra-console/web/e2e/operators.spec.ts new file mode 100644 index 0000000..653c38f --- /dev/null +++ b/bin/ra-console/web/e2e/operators.spec.ts @@ -0,0 +1,68 @@ +// Registre des opérateurs dans le navigateur (docs/WEBUI.md §10, §15 étape +// 6e) : invitation (jeton affiché une seule fois), changement de rôle, +// révocation de clé — chaque écriture signée et exécutée par ca-server. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, logIn, withOperatorKey } from "./helpers"; + +interface Registry { + operators: { name: string; role: string; credentials: { revoked_at: string | null }[] }[]; +} + +async function registry(page: Page): Promise { + return (await (await page.request.get("/api/v1/operators")).json()) as Registry; +} + +test("un administrateur invite, change un rôle et révoque une clé", async ({ page }) => { + const errors = collectErrors(page); + await withOperatorKey(page, "root"); + await page.goto("/"); + await logIn(page, "root"); + await page.getByTestId("nav-operators").click(); + + // Invitation : le jeton n'est montré qu'une fois. + await page.getByTestId("invite").click(); + await page.getByTestId("invite-name").fill("frank"); + await page.getByTestId("invite-role").selectOption("ra_operateur"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "invite_operator"`); + await page.getByTestId("sign").click(); + const token = page.getByTestId("invite-token"); + await expect(token).toHaveText(/^\S{30,}$/); + await page.getByTestId("token-close").click(); + await expect(page.getByTestId("token-dialog")).toHaveCount(0); + await expect(page.getByTestId("operator-frank")).toBeVisible(); + + // Rôle de dave : auditeur (pas de double contrôle hors rôle admin). + await page.getByTestId("operator-dave").click(); + await page.getByTestId("new-role").selectOption("auditeur"); + await page.getByTestId("change-role").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"operator": "dave"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("operators-status")).toContainText("auditeur"); + expect((await registry(page)).operators.find((o) => o.name === "dave")?.role).toBe("auditeur"); + + // Révocation de la clé de dave, motif obligatoire. + await page.getByTestId("operator-dave").click(); + await page.getByTestId("revoke-key-dave").click(); + await page.getByTestId("comment").fill("départ de l'association"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "revoke_key"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("operators-status")).toContainText("révoquée"); + const dave = (await registry(page)).operators.find((o) => o.name === "dave"); + expect(dave?.credentials.every((c) => c.revoked_at !== null)).toBe(true); + expect(errors).toEqual([]); +}); + +test("un non-administrateur consulte le registre sans pouvoir l'écrire", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await page.getByTestId("nav-operators").click(); + await expect(page.getByTestId("operator-root")).toBeVisible(); + await expect(page.getByTestId("invite")).toBeDisabled(); + await page.getByTestId("operator-root").click(); + await expect(page.getByTestId("change-role")).toBeDisabled(); + await expect(page.getByTestId("revoke-key-root")).toBeDisabled(); +}); diff --git a/bin/ra-console/web/src/operators.ts b/bin/ra-console/web/src/operators.ts new file mode 100644 index 0000000..58814a1 --- /dev/null +++ b/bin/ra-console/web/src/operators.ts @@ -0,0 +1,341 @@ +// Registre des opérateurs (docs/WEBUI.md §10, §15 étape 6e) : invitations, +// confirmation des clés en attente (empreinte comparée hors bande), révocation +// de clé, changement de rôle. Chaque écriture est une action signée que +// ca-server juge ; les boutons ne sont qu'un affichage pour les non-admins. + +import { call, isError, type Me } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; +import type { View } from "./view"; + +interface Credential { + credential_id: string; + label: string; + initiated_at: string; + revoked_at: string | null; +} +interface Operator { + name: string; + role: Me["role"]; + disabled: boolean; + credentials: Credential[]; +} +interface PendingKey { + credential_id: string; + operator: string; + key_fingerprint: string | null; + registered_at: string; + expires_at: string; +} +interface Registry { + operators: Operator[]; + pending: PendingKey[]; +} + +const ROLES: [Me["role"], string][] = [ + ["auditeur", "auditeur"], + ["ra_operateur", "opérateur RA"], + ["ca_operateur", "opérateur CA"], + ["admin", "administrateur"], +]; + +export function operatorsView(me: Me): View { + const isAdmin = me.role === "admin"; + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "operators-status" }); + const table = h("tbody", {}); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur" }); + const pendingList = h("div", { class: "quorum", "data-testid": "pending-keys" }); + const invite = h("button", { type: "button", class: "primary", "data-testid": "invite", ...(isAdmin ? {} : { disabled: "" }) }, "Inviter un opérateur…"); + const element = h( + "section", + {}, + h("h1", {}, "Opérateurs"), + isAdmin ? null : h("p", { class: "muted" }, "Consultation seule : la gestion du registre est réservée aux administrateurs."), + notice, + h("div", { class: "actions start" }, invite), + h( + "div", + { class: "split" }, + h( + "table", + { class: "dense", "data-testid": "operators" }, + h("thead", {}, h("tr", {}, h("th", {}, "Opérateur"), h("th", {}, "Rôle"), h("th", {}, "Clés actives"), h("th", {}, "État"))), + table, + ), + inspector, + ), + h("h2", {}, "Clés en attente de confirmation"), + pendingList, + ); + let registry: Registry = { operators: [], pending: [] }; + let selected = 0; + + const active = (o: Operator): Credential[] => o.credentials.filter((c) => c.revoked_at === null); + + const render = (): void => { + replace( + table, + ...registry.operators.map((o, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `operator-${o.name}` }, + h("td", {}, o.name), + h("td", {}, h("span", { class: `role role-${o.role}` }, o.role)), + h("td", { class: "mono" }, String(active(o).length)), + h("td", {}, o.disabled ? "désactivé" : "actif"), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + renderInspector(); + replace( + pendingList, + ...(registry.pending.length === 0 + ? [h("p", { class: "muted" }, "Aucune clé en attente.")] + : registry.pending.map((p) => { + const confirm = h( + "button", + { type: "button", class: "primary", "data-testid": `confirm-${p.operator}`, ...(isAdmin && p.key_fingerprint ? {} : { disabled: "" }) }, + "Confirmer la clé…", + ); + confirm.addEventListener("click", () => void confirmKey(p)); + return h( + "article", + { class: "card" }, + h("h2", {}, `Clé de ${p.operator}`), + h( + "dl", + {}, + h("dt", {}, "Empreinte"), + h("dd", { class: "mono hash" }, p.key_fingerprint ?? "illisible"), + h("dt", {}, "Enregistrée le"), + h("dd", { class: "mono" }, utc(p.registered_at)), + h("dt", {}, "Expire le"), + h("dd", { class: "mono" }, utc(p.expires_at)), + ), + h("div", { class: "actions" }, confirm), + ); + })), + ); + }; + + const renderInspector = (): void => { + const o = registry.operators[selected]; + if (o === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucun opérateur.")); + return; + } + const roleSelect = h( + "select", + { id: "new-role", "data-testid": "new-role", ...(isAdmin ? {} : { disabled: "" }) }, + ...ROLES.map(([value, label]) => { + const option = h("option", { value }, label); + if (value === o.role) option.selected = true; + return option; + }), + ); + const changeRole = h("button", { type: "button", "data-testid": "change-role", ...(isAdmin ? {} : { disabled: "" }) }, "Changer le rôle…"); + changeRole.addEventListener("click", () => void setRole(o, roleSelect.value as Me["role"])); + replace( + inspector, + h("h2", {}, o.name), + h("label", { for: "new-role" }, "Rôle"), + roleSelect, + h("div", { class: "actions" }, changeRole), + h("h2", {}, "Clés"), + ...o.credentials.map((c) => { + const revoke = h( + "button", + { type: "button", class: "danger", "data-testid": `revoke-key-${o.name}`, ...(isAdmin && c.revoked_at === null ? {} : { disabled: "" }) }, + "Révoquer…", + ); + revoke.addEventListener("click", () => void revokeKey(o, c)); + return h( + "div", + { class: "key" }, + h("p", { class: "mono" }, `${c.label} · ${c.credential_id.slice(0, 16)}…`), + h("p", { class: "muted" }, c.revoked_at ? `révoquée le ${utc(c.revoked_at)}` : `active depuis le ${utc(c.initiated_at)}`), + revoke, + ); + }), + ); + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/operators"); + if (reply.status === 200 && reply.body !== null && !isError(reply.body)) registry = reply.body; + else if (isError(reply.body)) notice.textContent = reply.body.message; + selected = Math.min(selected, Math.max(registry.operators.length - 1, 0)); + render(); + }; + + const after = async (result: Record | null, done: string): Promise => { + if (result === null) return; + notice.textContent = + result.status === "AWAITING_QUORUM" ? "Signature enregistrée : un second administrateur doit co-signer (salle de quorum)." : done; + await load(); + }; + + const setRole = async (o: Operator, role: Me["role"]): Promise => { + if (role === o.role) return; + const result = await sign({ + title: `Changement de rôle de ${o.name}`, + summary: [ + ["Opérateur", o.name], + ["Rôle actuel", o.role], + ["Nouveau rôle", role], + ], + action: { action: "set_role", operator: o.name, role }, + route: `/api/v1/operators/${encodeURIComponent(o.name)}/role`, + }); + await after(result, `Rôle de ${o.name} : ${role}.`); + }; + + const revokeKey = async (o: Operator, c: Credential): Promise => { + const reason = await ask("Motif de la révocation de la clé (obligatoire)", "revoke-reason"); + if (reason === null) return; + const result = await sign({ + title: `Révocation d'une clé de ${o.name}`, + summary: [ + ["Opérateur", o.name], + ["Clé", `${c.label} · ${c.credential_id}`], + ["Motif", reason], + ], + action: { action: "revoke_key", credential_id: c.credential_id, reason }, + route: `/api/v1/credentials/${encodeURIComponent(c.credential_id)}/revoke`, + }); + await after(result, `Clé de ${o.name} révoquée.`); + }; + + const confirmKey = async (p: PendingKey): Promise => { + if (p.key_fingerprint === null) return; + const checked = await confirmFingerprint(p); + if (!checked) return; + const result = await sign({ + title: `Confirmation de la clé de ${p.operator}`, + summary: [ + ["Opérateur", p.operator], + ["Empreinte comparée", p.key_fingerprint], + ], + action: { action: "confirm_key", credential_id: p.credential_id, key_fingerprint: p.key_fingerprint }, + route: `/api/v1/credentials/${encodeURIComponent(p.credential_id)}/confirm`, + }); + await after(result, `Clé de ${p.operator} confirmée.`); + }; + + invite.addEventListener("click", () => void inviteOperator()); + const inviteOperator = async (): Promise => { + const who = await askInvite(); + if (who === null) return; + const result = await sign({ + title: `Invitation de ${who.name}`, + summary: [ + ["Opérateur invité", who.name], + ["Rôle", who.role], + ], + action: { action: "invite_operator", name: who.name, role: who.role }, + route: "/api/v1/operators", + }); + if (result === null) return; + const inner = result.result as { invite_token?: string } | null | undefined; + if (result.status === "EXECUTED" && typeof inner?.invite_token === "string") { + await showToken(who.name, inner.invite_token); + } + await after(result, `Invitation de ${who.name} créée.`); + }; + + void load(); + return { element, dispose: () => undefined }; +} + +/// Le jeton d'invitation, affiché une seule fois : il n'est conservé nulle part, +/// ni par la console ni par ca-server (seul son haché l'est). +function showToken(name: string, token: string): Promise { + return new Promise((resolve) => { + const close = h("button", { type: "button", class: "primary", "data-testid": "token-close" }, "J'ai transmis le jeton"); + const dialog = h( + "dialog", + { class: "signature", "data-testid": "token-dialog" }, + h("h2", {}, `Jeton d'invitation de ${name}`), + h("p", {}, "Affiché une seule fois. Transmettez-le à l'invité par un canal distinct ; il lui sert à enregistrer sa clé FIDO2."), + h("pre", { class: "mono", "data-testid": "invite-token" }, token), + h("div", { class: "actions" }, close), + ); + const finish = (): void => { + dialog.close(); + dialog.remove(); + resolve(); + }; + close.addEventListener("click", finish); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +function formDialog(testid: string, title: string, fields: HTMLElement[], valid: () => boolean): Promise { + return new Promise((resolve) => { + const next = h("button", { type: "submit", class: "primary", "data-testid": "comment-next" }, "Préparer la signature"); + const cancel = h("button", { type: "button" }, "Annuler"); + const form = h("form", { method: "dialog" }, h("h2", {}, title), ...fields, h("div", { class: "actions" }, cancel, next)); + const dialog = h("dialog", { class: "signature", "data-testid": testid }, form); + const finish = (ok: boolean): void => { + dialog.close(); + dialog.remove(); + resolve(ok); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (valid()) finish(true); + }); + cancel.addEventListener("click", () => finish(false)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(false); + }); + document.body.append(dialog); + dialog.showModal(); + }); +} + +async function ask(title: string, testid: string): Promise { + const input = h("textarea", { rows: "3", maxlength: "1000", required: "", "data-testid": "comment" }); + const ok = await formDialog(testid, title, [input], () => input.value.trim() !== ""); + return ok ? input.value.trim() : null; +} + +async function askInvite(): Promise<{ name: string; role: Me["role"] } | null> { + const name = h("input", { id: "invite-name", required: "", maxlength: "100", "data-testid": "invite-name" }); + const role = h("select", { id: "invite-role", "data-testid": "invite-role" }, ...ROLES.map(([v, l]) => h("option", { value: v }, l))); + const ok = await formDialog( + "invite-dialog", + "Inviter un opérateur", + [h("label", { for: "invite-name" }, "Nom"), name, h("label", { for: "invite-role" }, "Rôle"), role], + () => name.value.trim() !== "", + ); + return ok ? { name: name.value.trim(), role: role.value as Me["role"] } : null; +} + +/// La confirmation n'a de sens que si l'administrateur a comparé l'empreinte +/// avec celle que l'invité lit sur son poste (§10) : il le déclare en cochant. +async function confirmFingerprint(p: PendingKey): Promise { + const box = h("input", { type: "checkbox", id: "compared", required: "", "data-testid": "compared" }); + return formDialog( + "confirm-dialog", + `Confirmer la clé de ${p.operator}`, + [ + h("p", {}, "Comparez cette empreinte, hors bande, avec celle que l'invité a obtenue en enregistrant sa clé :"), + h("p", { class: "mono hash" }, p.key_fingerprint ?? ""), + h("label", { for: "compared" }, box, " J'ai comparé l'empreinte avec l'invité : elle est identique."), + ], + () => box.checked, + ); +} diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts index a76cb8d..eef5850 100644 --- a/bin/ra-console/web/src/shell.ts +++ b/bin/ra-console/web/src/shell.ts @@ -7,6 +7,7 @@ import { call, isError, type ConsoleInfo, type Me } from "./api"; import { banner } from "./banner"; import { h, replace } from "./dom"; import { certificatesView } from "./certificates"; +import { operatorsView } from "./operators"; import { quorumView } from "./quorum"; import { requestsView } from "./requests"; import type { View } from "./view"; @@ -42,6 +43,7 @@ export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogo ["requests", "Demandes RA ", requests, () => requestsView(refresh)], ["certificates", "Certificats", null, () => certificatesView(refresh)], ["quorum", "Quorum ", quorum, () => quorumView(me, refresh)], + ["operators", "Opérateurs", null, () => operatorsView(me)], ]; const buttons = views.map(([id, label, count, make]) => { const button = h("button", { type: "button", class: "nav", "data-testid": `nav-${id}` }, label, count); diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css index 86b2258..fc629bc 100644 --- a/bin/ra-console/web/static/console.css +++ b/bin/ra-console/web/static/console.css @@ -382,3 +382,16 @@ select { max-height: 200px; font-size: 12px; } + +/* --- 6e : opérateurs --- */ +.actions.start { + justify-content: flex-start; + margin-bottom: 12px; +} +.key { + border-top: 1px solid var(--border-subtle); + padding: 8px 0; +} +.key p { + margin: 0 0 4px; +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index ad987dd..c10b937 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -203,6 +203,14 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). - **Salle de quorum (6c)** : les actions en attente, leur corps figé et leur empreinte, qui a déjà signé ; la co-signature est désactivée pour qui a déjà signé (« le double contrôle requiert un opérateur distinct » — `ca-server` la refuserait de toute façon). +- **Opérateurs (6e)** : `GET /api/v1/operators` (lecture seule : opérateurs, leurs clés, + clés en attente avec leur **empreinte recalculée par `oe_actions::key_fingerprint`**, + la fonction même de `ca-server`). L'écran liste le registre ; un administrateur y + invite (le **jeton s'affiche une seule fois**, à transmettre par un canal distinct), + confirme une clé en attente après avoir **déclaré avoir comparé l'empreinte hors + bande** (§10), révoque une clé (motif obligatoire) et change un rôle (le rôle `admin` + exige un second administrateur, en salle de quorum). Pour les autres rôles, ces + boutons sont désactivés — affichage seulement, `ca-server` juge. - L'explorateur d'audit suit (6d, après #51). ## Variables d'environnement @@ -264,9 +272,9 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). ## Ce qui n'existe pas encore -La liste des clés en attente de confirmation, le libre-service (ajout et retrait de ses -propres clés, §10), le workflow d'incident et le frontend : voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La -connexion, les sessions et la lecture (`/api/v1/requests`) existent, mais ne sont pas -encore décrites ici. Le certificat client (3 mois) se renouvelle à la main pour -l'instant. Aucun job de -démonstration réelle (kind, compose) ne déploie encore la console. +Le libre-service (ajout et retrait de ses propres clés, §10), l'explorateur d'audit +dans le navigateur (6d, sur `GET /api/v1/audit/search`) et le workflow d'incident : +voir [WEBUI.md](WEBUI.md) §15 et `TODO.md`. La connexion, les sessions et la lecture +(`/api/v1/requests`) existent, mais ne sont pas encore décrites ici. Le certificat +client (3 mois) se renouvelle à la main pour l'instant. Aucun job de démonstration +réelle (kind, compose) ne déploie encore la console.