diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs index 8e080c9..1dd0291 100644 --- a/bin/ra-console/examples/e2e_console.rs +++ b/bin/ra-console/examples/e2e_console.rs @@ -48,7 +48,8 @@ async fn main() { .await .unwrap(); let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0); - let _ = oe_castore::Postgres::open(&dsn).await.unwrap(); + let store: Arc = + Arc::new(oe_castore::Postgres::open(&dsn).await.unwrap()); let registry = Registry::connect(&dsn).await.unwrap(); let (token, root) = SoftToken::new(true).unwrap(); @@ -116,8 +117,10 @@ async fn main() { .private_key_to_pkcs8() .unwrap(); let b64 = base64::engine::general_purpose::STANDARD; + let pending: Vec = (1..=8).map(|i| format!("tx-e2e-{i}")).collect(); let out = serde_json::json!({ "origin": origin.as_str().trim_end_matches('/'), + "pending": pending, "operator": "alice", "role": "ra_operateur", "credential": { @@ -134,14 +137,50 @@ async fn main() { } std::fs::write(&fixture, serde_json::to_vec_pretty(&out).unwrap()).unwrap(); - // Un lien vers ca-server injoignable : la connexion et le poste n'en ont - // pas besoin (seul /healthz s'en soucie). + // Des demandes d'enrôlement en attente, pour les écrans de décision (6b). + for (i, tx) in pending.iter().enumerate() { + store + .create_request(oe_castore::Request { + transaction_id: tx.clone(), + csr_fingerprint: format!("empreinte-{tx}"), + csr_der: vec![0x30, 0x00], + profile: "tsa_signer".to_string(), + subject_cn: format!("tsu-{}.example.test", i + 1), + state: oe_castore::RequestState::Pending, + created_at: now, + decided_at: None, + operator: String::new(), + comment: String::new(), + issued_at: None, + certificate_serial: None, + }) + .await + .unwrap(); + } + + // Le vrai service d'actions de ca-server, derrière son routeur interne et le + // lien mTLS : les décisions signées dans le navigateur y sont vérifiées. + let service = Arc::new(oe_actions::Service::new( + registry.clone(), + verifier(), + store.clone(), + oe_raflow::Decider::new(oe_raflow::DeciderOptions { + store: store.clone(), + recorder: None, + clock: None, + }), + Arc::new(NullJournal), + Arc::new(time::OffsetDateTime::now_utc), + )); let pki = common::pki().await; + let internal = pki + .serve_router(ca_server::internal::router(service, 64 * 1024)) + .await; let dir = common::tempdir::Dir::new(); let client = pki .cert(&oe_ca_core::profile::internal_client(), "ra-console") .await; - let link = CaLink::new(&pki.files(&dir, &client, 9)).unwrap(); + let link = CaLink::new(&pki.files(&dir, &client, internal)).unwrap(); let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); let console = app( Arc::new(AppState { @@ -209,3 +248,12 @@ fn bytes_of(values: &[serde_cbor_2::Value]) -> Vec { }) .collect() } + +struct NullJournal; + +#[async_trait::async_trait] +impl oe_raflow::Recorder for NullJournal { + async fn append(&self, _: &str, _: serde_json::Value) -> Result<(), String> { + Ok(()) + } +} diff --git a/bin/ra-console/web/dist/console.css b/bin/ra-console/web/dist/console.css index 15b2bc1..e3c4755 100644 --- a/bin/ra-console/web/dist/console.css +++ b/bin/ra-console/web/dist/console.css @@ -210,3 +210,117 @@ button.quiet { color: var(--undeclared-text); border-bottom: 1px dashed var(--undeclared-text); } + +/* --- 6b : file des demandes, inspecteur, modale de signature (UI-UX §3.1, §5) --- */ + +.mono { + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.split { + display: grid; + grid-template-columns: 1fr minmax(420px, 34%); + gap: 16px; + align-items: start; +} +table.dense { + width: 100%; + border-collapse: collapse; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); +} +table.dense th { + position: sticky; + top: 0; + background: var(--bg-surface-elevated); + text-align: left; + font-size: 12px; + color: var(--text-muted); +} +table.dense th, +table.dense td { + padding: 8px 10px; + height: 38px; + border-bottom: 1px solid var(--border-subtle); +} +table.dense tr[aria-selected="true"] td { + background: var(--bg-surface-elevated); +} +table.dense tr[aria-selected="true"] td:first-child { + box-shadow: inset 3px 0 0 var(--border-focus); +} +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; +} +.badge.pending { + background: #451a03; + color: #fbbf24; + border: 1px dashed #d97706; +} +.inspector { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 16px; +} +.inspector h2, +dialog.signature h2 { + font-size: 16px; + margin: 0 0 12px; +} +dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0 0 16px; +} +dt { + color: var(--text-muted); +} +dd { + margin: 0; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +dialog.signature { + width: min(640px, 92vw); + background: var(--bg-surface-elevated); + color: var(--text-main); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 24px; +} +dialog.signature::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +dialog.signature textarea { + width: 100%; + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; +} +.frozen pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 240px; + font-size: 12px; +} +.hash { + font-size: 13px; + word-spacing: 0.4em; +} diff --git a/bin/ra-console/web/dist/console.js b/bin/ra-console/web/dist/console.js index be34a73..7cae5be 100644 --- a/bin/ra-console/web/dist/console.js +++ b/bin/ra-console/web/dist/console.js @@ -1 +1 @@ -async function d(e,r,n){let o={method:e,credentials:"same-origin",headers:{}};n!==void 0&&(o.headers={"Content-Type":"application/json"},o.body=JSON.stringify(n));let t=await fetch(r,o),a=await t.text(),s=null;if(a!=="")try{s=JSON.parse(a)}catch{s=null}return{status:t.status,body:s}}function u(e){return typeof e=="object"&&e!==null&&"error"in e}var b=["keydown","pointerdown","wheel","touchstart"];function w(e,r){let n=0,o=0,t=()=>{window.clearTimeout(n),window.clearTimeout(o),n=window.setTimeout(e,84e4),o=window.setTimeout(r,9e5)};for(let a of b)window.addEventListener(a,t,{passive:!0});return t(),()=>{window.clearTimeout(n),window.clearTimeout(o);for(let a of b)window.removeEventListener(a,t)}}function i(e,r={},...n){let o=document.createElement(e);for(let[t,a]of Object.entries(r))o.setAttribute(t,a);for(let t of n)t==null||t===!1||o.append(typeof t=="string"?document.createTextNode(t):t);return o}function f(e,...r){e.replaceChildren(...r)}var I={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function g(e){return i("div",{class:`env-banner env-${e.environment}`,role:"status","data-testid":"env-banner"},I[e.environment])}function p(e){let r=new Uint8Array(e),n="";for(let o of r)n+=String.fromCharCode(o);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function v(e){let r=e.replace(/-/g,"+").replace(/_/g,"/"),n=r+"=".repeat((4-r.length%4)%4),o=atob(n),t=new Uint8Array(o.length);for(let a=0;a({type:t.type,id:v(t.id),...t.transports?{transports:t.transports}:{}})),...e.timeout!==void 0?{timeout:e.timeout}:{},...e.rpId!==void 0?{rpId:e.rpId}:{},...e.userVerification!==void 0?{userVerification:e.userVerification}:{}},n=await navigator.credentials.get({publicKey:r});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let o=n.response;return{id:n.id,rawId:p(n.rawId),type:n.type,response:{clientDataJSON:p(o.clientDataJSON),authenticatorData:p(o.authenticatorData),signature:p(o.signature),userHandle:o.userHandle?p(o.userHandle):null},extensions:{}}}function T(e,r,n,o){let t=i("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},o??""),a=i("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),s=i("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),l=i("form",{class:"login-form","aria-labelledby":"login-title"},i("h1",{id:"login-title"},"Console d'op\xE9ration Open eIDAS"),i("label",{for:"operator-name"},"Nom d'op\xE9rateur"),a,s,t);l.addEventListener("submit",c=>{c.preventDefault(),x(a.value.trim(),s,t,n)}),f(e,g(r),i("main",{class:"login"},l)),a.focus()}async function x(e,r,n,o){if(e!==""){r.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let t=await d("POST","/api/v1/webauthn/login/begin",{name:e});if(t.status!==200||t.body===null||u(t.body)){n.textContent="Connexion impossible pour le moment.";return}let a=await h(t.body.webauthn),s=await d("POST","/api/v1/webauthn/login/finish",{challenge_id:t.body.challenge_id,credential:a});if(s.status!==200||s.body===null||u(s.body)){n.textContent="Identifiants invalides.";return}o(s.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{r.disabled=!1}}}var L={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function E(e,r,n,o){let t=i("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");t.addEventListener("click",o);let a=i("header",{class:"security-bar"},i("span",{class:"brand"},"Open eIDAS"),i("span",{class:"identity"},i("span",{class:"operator","data-testid":"operator"},n.operator),i("span",{class:`role role-${n.role}`,"data-testid":"role"},L[n.role])),t),s=i("span",{class:"count","data-testid":"count-requests"},"\u2026"),l=i("span",{class:"count","data-testid":"count-quorum"},"\u2026"),c=i("nav",{class:"sidebar","aria-label":"Files de travail"},i("ul",{},i("li",{},"Demandes RA ",s),i("li",{},"Quorum ",l))),m=i("main",{class:"workspace",tabindex:"-1"},i("h1",{},"Files de travail"),i("p",{class:"muted"},"Les \xE9crans de d\xE9cision arrivent avec les \xE9tapes suivantes."));f(e,g(r),a,i("div",{class:"layout"},c,m)),M(s,l)}async function M(e,r){let[n,o]=await Promise.all([d("GET","/api/v1/requests?state=PENDING"),d("GET","/api/v1/quorum?state=PENDING")]);e.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",r.textContent=Array.isArray(o.body)?`(${o.body.length})`:"(\u2014)",(u(n.body)||u(o.body))&&(e.title=r.title="compteur indisponible")}function C(e){let r=i("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 e.prepend(r),r}async function N(e){let r=await d("GET","/api/v1/console"),n=r.body!==null&&!u(r.body)?r.body:{environment:"undeclared",version:"?"},o=null,t=c=>{o?.(),o=null,T(e,n,s,c)},a=async c=>{await d("POST","/api/v1/logout"),t(c)},s=c=>{E(e,n,c,()=>{a()});let m=null;o=w(()=>{m??=C(e)},()=>{a("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let y=()=>{m?.remove(),m=null};window.addEventListener("keydown",y),window.addEventListener("pointerdown",y)},l=await d("GET","/api/v1/me");l.status===200&&l.body!==null&&!u(l.body)?s(l.body):t()}var A=document.getElementById("app");A!==null&&N(A); +async function g(t,a,n){let o={method:t,credentials:"same-origin",headers:{}};n!==void 0&&(o.headers={"Content-Type":"application/json"},o.body=JSON.stringify(n));let r=await fetch(a,o),i=await r.text(),l=null;if(i!=="")try{l=JSON.parse(i)}catch{l=null}return{status:r.status,body:l}}function y(t){return typeof t=="object"&&t!==null&&"error"in t}var L=["keydown","pointerdown","wheel","touchstart"];function S(t,a){let n=0,o=0,r=()=>{window.clearTimeout(n),window.clearTimeout(o),n=window.setTimeout(t,84e4),o=window.setTimeout(a,9e5)};for(let i of L)window.addEventListener(i,r,{passive:!0});return r(),()=>{window.clearTimeout(n),window.clearTimeout(o);for(let i of L)window.removeEventListener(i,r)}}function e(t,a={},...n){let o=document.createElement(t);for(let[r,i]of Object.entries(a))o.setAttribute(r,i);for(let r of n)r==null||r===!1||o.append(typeof r=="string"?document.createTextNode(r):r);return o}function v(t,...a){t.replaceChildren(...a)}var O={production:"PRODUCTION",staging:"STAGING",demo:"D\xC9MONSTRATION",undeclared:"ENVIRONNEMENT NON D\xC9CLAR\xC9"};function T(t){return e("div",{class:`env-banner env-${t.environment}`,role:"status","data-testid":"env-banner"},O[t.environment])}function h(t){let a=new Uint8Array(t),n="";for(let o of a)n+=String.fromCharCode(o);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"")}function C(t){let a=t.replace(/-/g,"+").replace(/_/g,"/"),n=a+"=".repeat((4-a.length%4)%4),o=atob(n),r=new Uint8Array(o.length);for(let i=0;i({type:r.type,id:C(r.id),...r.transports?{transports:r.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:a});if(n===null)throw new Error("aucune cl\xE9 n'a r\xE9pondu");let o=n.response;return{id:n.id,rawId:h(n.rawId),type:n.type,response:{clientDataJSON:h(o.clientDataJSON),authenticatorData:h(o.authenticatorData),signature:h(o.signature),userHandle:o.userHandle?h(o.userHandle):null},extensions:{}}}function _(t,a,n,o){let r=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"login-status"},o??""),i=e("input",{id:"operator-name",name:"operator",autocomplete:"username webauthn",required:"",maxlength:"256","data-testid":"login-name"}),l=e("button",{type:"submit",class:"primary","data-testid":"login-submit"},"Se connecter avec ma cl\xE9 FIDO2"),c=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"),i,l,r);c.addEventListener("submit",u=>{u.preventDefault(),D(i.value.trim(),l,r,n)}),v(t,T(a),e("main",{class:"login"},c)),i.focus()}async function D(t,a,n,o){if(t!==""){a.disabled=!0,n.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let r=await g("POST","/api/v1/webauthn/login/begin",{name:t});if(r.status!==200||r.body===null||y(r.body)){n.textContent="Connexion impossible pour le moment.";return}let i=await x(r.body.webauthn),l=await g("POST","/api/v1/webauthn/login/finish",{challenge_id:r.body.challenge_id,credential:i});if(l.status!==200||l.body===null||y(l.body)){n.textContent="Identifiants invalides.";return}o(l.body)}catch{n.textContent="La cl\xE9 n'a pas r\xE9pondu. R\xE9essayez."}finally{a.disabled=!1}}}function A(t){let a=new Date(t);return Number.isNaN(a.getTime())?t:`${a.toISOString().slice(0,19).replace("T"," ")} UTC`}function I(t){return(t.match(/.{1,8}/g)??[]).join(" ")}var H={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 M(t,a){return y(t)?H[t.error]??t.message:a}function R(t){return new Promise(a=>{let n=!1,o=!1,r=null,i=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"sign-status"}),l=e("div",{class:"frozen","data-testid":"frozen"}),c=e("button",{type:"button",class:"primary","data-testid":"sign"},"Signer avec ma cl\xE9 FIDO2"),u=e("button",{type:"button","data-testid":"sign-cancel"},"Annuler"),f=e("dl",{class:"summary"},...t.summary.flatMap(([d,m])=>[e("dt",{},d),e("dd",{},m)])),p=e("dialog",{class:"signature","aria-labelledby":"sign-title","data-testid":"sign-dialog"},e("h2",{id:"sign-title"},`\u{1F511} ${t.title}`),f,l,i,e("div",{class:"actions"},u,c)),b=d=>{p.close(),p.remove(),a(d)},E=async()=>{c.disabled=!0,i.textContent="Pr\xE9paration de l'action par l'autorit\xE9\u2026";let d=await g("POST","/api/v1/webauthn/challenge",t.action);return d.status!==200||d.body===null||y(d.body)?(i.textContent=M(d.body,"L'autorit\xE9 n'a pas pu pr\xE9parer l'action."),!1):(r=d.body,v(l,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(r.body,null,2)),e("p",{class:"muted"},"Empreinte de la requ\xEAte (SHA-256) :"),e("p",{class:"mono hash","data-testid":"body-hash"},I(r.body_hash))),i.textContent="",c.disabled=!1,!0)},s=async()=>{if(r===null&&!await E())return;let d=r;if(d!==null){n=!0,c.disabled=!0,u.disabled=!0,i.textContent="Touchez votre cl\xE9 de s\xE9curit\xE9 mat\xE9rielle\u2026";try{let m=await x(d.webauthn),w=await g("POST",t.route,{challenge_id:d.challenge_id,assertion:m});if(w.status===200){o=!0,i.textContent="\u2713 Action sign\xE9e et ex\xE9cut\xE9e.",window.setTimeout(()=>b(!0),800);return}r=null,i.textContent=M(w.body,"L'autorit\xE9 a refus\xE9 l'action.")}catch{i.textContent="La cl\xE9 n'a pas r\xE9pondu (annulation ou d\xE9lai). R\xE9essayez."}finally{n=!1,o||(c.disabled=!1,u.disabled=!1)}}};p.addEventListener("cancel",d=>{d.preventDefault(),!n&&!o&&b(!1)}),u.addEventListener("click",()=>{n||b(!1)}),c.addEventListener("click",()=>{s()}),document.body.append(p),p.showModal(),E().then(()=>c.focus())})}function N(t){let a=[],n=0,o=e("tbody",{}),r=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"))),o),i=e("aside",{class:"inspector","aria-label":"Inspecteur","data-testid":"inspector"}),l=e("p",{class:"status",role:"status","aria-live":"polite","data-testid":"requests-status"}),c=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"),l,e("div",{class:"split"},r,i)),u=()=>{v(o,...a.map((s,d)=>{let m=e("tr",{"aria-selected":String(d===n),"data-testid":`row-${s.transaction_id}`},e("td",{},e("span",{class:"badge pending"},"\u23F3 en attente")),e("td",{class:"mono"},s.transaction_id),e("td",{},s.subject_cn),e("td",{},s.profile),e("td",{class:"mono"},A(s.created_at)));return m.addEventListener("click",()=>{n=d,u()}),m})),a.length===0&&v(o,e("tr",{},e("td",{colspan:"5",class:"muted"},"Aucune demande en attente."))),f()},f=()=>{let s=a[n];if(s===void 0){v(i,e("p",{class:"muted"},"Aucune demande s\xE9lectionn\xE9e."));return}let d=e("button",{type:"button",class:"primary","data-testid":"approve"},"Approuver la demande\u2026"),m=e("button",{type:"button","data-testid":"reject"},"Rejeter la demande\u2026");d.addEventListener("click",()=>{p(s,"approve")}),m.addEventListener("click",()=>{p(s,"reject")}),v(i,e("h2",{},"G\xE9n\xE9ral"),e("dl",{},e("dt",{},"Transaction"),e("dd",{class:"mono"},s.transaction_id),e("dt",{},"Sujet (CN)"),e("dd",{},s.subject_cn),e("dt",{},"Profil"),e("dd",{},s.profile),e("dt",{},"D\xE9pos\xE9e le"),e("dd",{class:"mono"},A(s.created_at))),e("div",{class:"actions"},d,m))},p=async(s,d)=>{let m=await $(d);if(m===null)return;let w=d==="approve";await R({title:w?`Approbation de la demande ${s.transaction_id}`:`Rejet de la demande ${s.transaction_id}`,summary:[["Action",w?"approuver l'\xE9mission du certificat":"rejeter d\xE9finitivement la demande"],["Transaction",s.transaction_id],["Sujet (CN)",s.subject_cn],["Profil",s.profile],["Justification",m===""?"\u2014":m]],action:{action:w?"approve_request":"reject_request",transaction_id:s.transaction_id,comment:m},route:`/api/v1/requests/${encodeURIComponent(s.transaction_id)}/${d}`})&&(l.textContent=w?`Demande ${s.transaction_id} approuv\xE9e.`:`Demande ${s.transaction_id} rejet\xE9e.`,await b(),t())},b=async()=>{let s=await g("GET","/api/v1/requests?state=PENDING");s.status!==200||!Array.isArray(s.body)?(l.textContent=y(s.body)?s.body.message:"File indisponible.",a=[]):a=s.body,n=Math.min(n,Math.max(a.length-1,0)),u()},E=s=>{let d=s.target;if(document.querySelector("dialog[open]")!==null||d!==null&&["INPUT","TEXTAREA","SELECT"].includes(d.tagName))return;let m=a[n];if(s.key==="j"||s.key==="ArrowDown")n=Math.min(n+1,a.length-1),u();else if(s.key==="k"||s.key==="ArrowUp")n=Math.max(n-1,0),u();else if(s.key==="a"&&m!==void 0)p(m,"approve");else if(s.key==="r"&&m!==void 0)p(m,"reject");else return;s.preventDefault()};return window.addEventListener("keydown",E),b(),{element:c,dispose:()=>window.removeEventListener("keydown",E)}}function $(t){return new Promise(a=>{let n=t==="reject",o=e("textarea",{id:"decision-comment",rows:"3",maxlength:"1000","data-testid":"comment",...n?{required:""}:{}}),r=e("button",{type:"submit",class:"primary","data-testid":"comment-next"},"Pr\xE9parer la signature"),i=e("button",{type:"button"},"Annuler"),l=e("form",{method:"dialog"},e("h2",{},t==="reject"?"Motif du rejet (obligatoire)":"Commentaire (facultatif)"),e("label",{for:"decision-comment"},"Justification consign\xE9e au journal"),o,e("div",{class:"actions"},i,r)),c=e("dialog",{class:"signature","data-testid":"comment-dialog"},l),u=f=>{c.close(),c.remove(),a(f)};l.addEventListener("submit",f=>{f.preventDefault();let p=o.value.trim();if(n&&p===""){o.focus();return}u(p)}),i.addEventListener("click",()=>u(null)),c.addEventListener("cancel",f=>{f.preventDefault(),u(null)}),document.body.append(c),c.showModal(),o.focus()})}var B={auditeur:"auditeur",ra_operateur:"op\xE9rateur RA",ca_operateur:"op\xE9rateur CA",admin:"administrateur"};function k(t,a,n,o){let r=e("button",{type:"button",class:"quiet","data-testid":"logout"},"Se d\xE9connecter");r.addEventListener("click",o);let i=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"},B[n.role])),r),l=e("span",{class:"count","data-testid":"count-requests"},"\u2026"),c=e("span",{class:"count","data-testid":"count-quorum"},"\u2026"),u=e("nav",{class:"sidebar","aria-label":"Files de travail"},e("ul",{},e("li",{},"Demandes RA ",l),e("li",{},"Quorum ",c))),f=N(()=>{P(l,c)}),p=e("main",{class:"workspace",tabindex:"-1"},f.element);return v(t,T(a),i,e("div",{class:"layout"},u,p)),P(l,c),f.dispose}async function P(t,a){let[n,o]=await Promise.all([g("GET","/api/v1/requests?state=PENDING"),g("GET","/api/v1/quorum?state=PENDING")]);t.textContent=Array.isArray(n.body)?`(${n.body.length})`:"(\u2014)",a.textContent=Array.isArray(o.body)?`(${o.body.length})`:"(\u2014)",(y(n.body)||y(o.body))&&(t.title=a.title="compteur indisponible")}function j(t){let a=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(a),a}async function G(t){let a=await g("GET","/api/v1/console"),n=a.body!==null&&!y(a.body)?a.body:{environment:"undeclared",version:"?"},o=null,r=null,i=f=>{o?.(),o=null,r?.(),r=null,_(t,n,c,f)},l=async f=>{await g("POST","/api/v1/logout"),i(f)},c=f=>{r=k(t,n,f,()=>{l()});let p=null;o=S(()=>{p??=j(t)},()=>{l("Session verrouill\xE9e apr\xE8s 15 minutes d'inactivit\xE9 : reconnectez-vous avec votre cl\xE9.")});let b=()=>{p?.remove(),p=null};window.addEventListener("keydown",b),window.addEventListener("pointerdown",b)},u=await g("GET","/api/v1/me");u.status===200&&u.body!==null&&!y(u.body)?c(u.body):i()}var q=document.getElementById("app");q!==null&&G(q); diff --git a/bin/ra-console/web/e2e/console.spec.ts b/bin/ra-console/web/e2e/console.spec.ts index 14261bf..a89cb83 100644 --- a/bin/ra-console/web/e2e/console.spec.ts +++ b/bin/ra-console/web/e2e/console.spec.ts @@ -2,60 +2,8 @@ // docs/UI-UX.md §6.3) : en-têtes de sécurité, connexion par clé FIDO2, // déconnexion, verrouillage après inactivité. -import { expect, test, type Page } from "@playwright/test"; -import { readFileSync } from "node:fs"; -import { fixturePath } from "../playwright.config"; - -interface Fixture { - operator: string; - credential: Record & { signCount: number }; -} - -const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; - -// Chaque test recrée un authentificateur : son compteur doit dépasser celui -// déjà vu par la console, sans quoi elle détecte (à juste titre) un clone. -let signCountBase = 1000; - -async function withOperatorKey(page: Page): Promise { - const cdp = await page.context().newCDPSession(page); - await cdp.send("WebAuthn.enable"); - const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { - options: { - protocol: "ctap2", - // La clé de test a été enregistrée par le SoftToken, qui s'annonce - // `internal` : le navigateur ne consulte que les authentificateurs de - // ce transport (les options relaient les transports enregistrés). - transport: "internal", - hasResidentKey: false, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); - signCountBase += 1000; - await cdp.send("WebAuthn.addCredential", { - authenticatorId, - credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, - } as never); -} - -/// Exceptions de la page et violations de CSP : aucune n'est admise. Les -/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. -function collectErrors(page: Page): string[] { - const errors: string[] = []; - page.on("console", (m) => { - if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); - }); - page.on("pageerror", (e) => errors.push(e.message)); - return errors; -} - -async function logIn(page: Page): Promise { - await page.getByTestId("login-name").fill(fixture().operator); - await page.getByTestId("login-submit").click(); - await expect(page.getByTestId("operator")).toHaveText(fixture().operator); -} +import { expect, test } from "@playwright/test"; +import { collectErrors, logIn, withOperatorKey } from "./helpers"; test("chaque réponse porte les en-têtes de sécurité, sans script en ligne", async ({ request }) => { for (const path of ["/", "/assets/console.js", "/api/v1/console", "/api/v1/me"]) { @@ -79,7 +27,7 @@ test("connexion par clé FIDO2, puis déconnexion qui révoque la session", asyn await expect(page.getByTestId("env-banner")).toHaveText("STAGING"); await logIn(page); await expect(page.getByTestId("role")).toHaveText("opérateur RA"); - await expect(page.getByTestId("count-requests")).toHaveText("(0)"); + await expect(page.getByTestId("count-requests")).toHaveText(/^\(\d+\)$/); await expect(page.getByTestId("count-quorum")).toHaveText("(0)"); await page.getByTestId("logout").click(); diff --git a/bin/ra-console/web/e2e/helpers.ts b/bin/ra-console/web/e2e/helpers.ts new file mode 100644 index 0000000..4cf2557 --- /dev/null +++ b/bin/ra-console/web/e2e/helpers.ts @@ -0,0 +1,60 @@ +// Aides communes aux parcours Playwright : la fixture écrite par +// examples/e2e_console.rs, la clé de l'opérateur confiée à l'authentificateur +// WebAuthn virtuel, la connexion. + +import { expect, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fixturePath } from "../playwright.config"; + +export interface Fixture { + operator: string; + pending: string[]; + credential: Record & { signCount: number }; +} + +export const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +// Chaque test recrée un authentificateur : son compteur doit dépasser ceux +// déjà vus par la console et par ca-server, sans quoi ils détectent (à juste +// titre) un clone. Module partagé : le compteur croît d'un fichier à l'autre. +let signCountBase = 1000; + +export async function withOperatorKey(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + // La clé de test a été enregistrée par le SoftToken, qui s'annonce + // `internal` : le navigateur ne consulte que les authentificateurs de + // ce transport (les options relaient les transports enregistrés). + transport: "internal", + hasResidentKey: false, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + signCountBase += 1000; + await cdp.send("WebAuthn.addCredential", { + authenticatorId, + credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, + } as never); +} + +/// Exceptions de la page et violations de CSP : aucune n'est admise. Les +/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. +export function collectErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (m) => { + if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); + }); + page.on("pageerror", (e) => errors.push(e.message)); + return errors; +} + +export async function logIn(page: Page): Promise { + await page.getByTestId("login-name").fill(fixture().operator); + await page.getByTestId("login-submit").click(); + await expect(page.getByTestId("operator")).toHaveText(fixture().operator); +} diff --git a/bin/ra-console/web/e2e/requests.spec.ts b/bin/ra-console/web/e2e/requests.spec.ts new file mode 100644 index 0000000..a5672b4 --- /dev/null +++ b/bin/ra-console/web/e2e/requests.spec.ts @@ -0,0 +1,85 @@ +// Décisions RA signées dans le navigateur (docs/WEBUI.md §15 étape 6b, +// docs/UI-UX.md §3.1, §3.4) : la modale affiche le corps figé par ca-server et +// son empreinte, la clé signe, ca-server exécute ; au clavier comme à la souris. + +import { expect, test, type Page } from "@playwright/test"; +import { collectErrors, fixture, logIn, withOperatorKey } from "./helpers"; + +async function stateOf(page: Page, tx: string): Promise<{ state: string; operator: string | null } | undefined> { + for (const state of ["PENDING", "APPROVED", "REJECTED"]) { + const list = (await (await page.request.get(`/api/v1/requests?state=${state}`)).json()) as { + transaction_id: string; + state: string; + operator: string | null; + }[]; + const found = list.find((r) => r.transaction_id === tx); + if (found) return found; + } + return undefined; +} + +test("approuver une demande : corps figé affiché, signé, exécuté par ca-server", async ({ page }) => { + const errors = collectErrors(page); + const tx = fixture().pending[0]!; + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + + await page.getByTestId(`row-${tx}`).click(); + await page.getByTestId("approve").click(); + await page.getByTestId("comment").fill("identité vérifiée au guichet"); + await page.getByTestId("comment-next").click(); + + // WYSIWYS : ce qui sera exécuté, tel que ca-server l'a figé, et son empreinte. + const body = page.getByTestId("frozen-body"); + await expect(body).toContainText(`"transaction_id": "${tx}"`); + await expect(body).toContainText(`"action": "approve_request"`); + await expect(body).toContainText("identité vérifiée au guichet"); + await expect(page.getByTestId("body-hash")).toHaveText(/^([0-9a-f]{8} ){7}[0-9a-f]{8}$/); + + await page.getByTestId("sign").click(); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + await expect(page.getByTestId("requests-status")).toContainText(`${tx} approuvée`); + await expect(page.getByTestId(`row-${tx}`)).toHaveCount(0); + expect(await stateOf(page, tx)).toMatchObject({ state: "APPROVED", operator: fixture().operator }); + expect(errors).toEqual([]); +}); + +test("rejeter au clavier : motif obligatoire, puis signature", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + await expect(page.locator("tr[aria-selected='true']")).toHaveCount(1); + + // j déplace la sélection ; r ouvre le rejet de la demande sélectionnée. + const first = await page.locator("tr[aria-selected='true']").getAttribute("data-testid"); + await page.keyboard.press("j"); + const second = await page.locator("tr[aria-selected='true']").getAttribute("data-testid"); + expect(second).not.toBe(first); + const tx = second!.replace("row-", ""); + await page.keyboard.press("r"); + + // Sans motif, rien ne part. + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("comment-dialog")).toBeVisible(); + await page.getByTestId("comment").fill("sujet non reconnu"); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(`"action": "reject_request"`); + await page.getByTestId("sign").click(); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + expect(await stateOf(page, tx)).toMatchObject({ state: "REJECTED" }); +}); + +test("renoncer avant de signer ne décide rien", async ({ page }) => { + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + const tx = fixture().pending[5]!; + await page.getByTestId(`row-${tx}`).click(); + await page.getByTestId("approve").click(); + await page.getByTestId("comment-next").click(); + await expect(page.getByTestId("frozen-body")).toContainText(tx); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("sign-dialog")).toBeHidden(); + expect(await stateOf(page, tx)).toMatchObject({ state: "PENDING" }); +}); diff --git a/bin/ra-console/web/src/format.ts b/bin/ra-console/web/src/format.ts new file mode 100644 index 0000000..f3cf765 --- /dev/null +++ b/bin/ra-console/web/src/format.ts @@ -0,0 +1,13 @@ +// Formats d'affichage de docs/UI-UX.md §4.2 : dates ISO 8601 en UTC, +// empreintes groupées, identifiants en monospace (le style s'en charge). + +export function utc(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${d.toISOString().slice(0, 19).replace("T", " ")} UTC`; +} + +/// Empreinte hexadécimale groupée par 8 caractères, lisible à voix haute. +export function groupedHash(hex: string): string { + return (hex.match(/.{1,8}/g) ?? []).join(" "); +} diff --git a/bin/ra-console/web/src/main.ts b/bin/ra-console/web/src/main.ts index d8b517b..a516e6e 100644 --- a/bin/ra-console/web/src/main.ts +++ b/bin/ra-console/web/src/main.ts @@ -13,10 +13,13 @@ async function boot(root: HTMLElement): Promise { : { environment: "undeclared", version: "?" }; let stopIdle: (() => void) | null = null; + let disposeView: (() => void) | null = null; const showLogin = (notice?: string): void => { stopIdle?.(); stopIdle = null; + disposeView?.(); + disposeView = null; renderLogin(root, info, showShell, notice); }; @@ -26,7 +29,7 @@ async function boot(root: HTMLElement): Promise { }; const showShell = (me: Me): void => { - renderShell(root, info, me, () => void logout()); + disposeView = renderShell(root, info, me, () => void logout()); let warning: HTMLElement | null = null; stopIdle = watchIdle( () => { diff --git a/bin/ra-console/web/src/requests.ts b/bin/ra-console/web/src/requests.ts new file mode 100644 index 0000000..096f0e5 --- /dev/null +++ b/bin/ra-console/web/src/requests.ts @@ -0,0 +1,215 @@ +// File des demandes d'enrôlement (docs/UI-UX.md §3.4, §5.1, §6.1) : tableau +// dense, sélection au clavier (j/k), inspecteur latéral, décisions signées. + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { utc } from "./format"; +import { sign } from "./sign"; + +interface EnrollmentRequest { + transaction_id: string; + profile: string; + subject_cn: string; + state: string; + operator: string | null; + created_at: string; +} + +export interface RequestsView { + element: HTMLElement; + /// Retire les raccourcis clavier quand la vue est quittée. + dispose: () => void; +} + +export function requestsView(onDecided: () => void): RequestsView { + let rows: EnrollmentRequest[] = []; + let selected = 0; + + const body = h("tbody", {}); + const table = h( + "table", + { class: "dense", "aria-label": "Demandes en attente", "data-testid": "requests" }, + h( + "thead", + {}, + h("tr", {}, h("th", {}, "Statut"), h("th", {}, "Transaction"), h("th", {}, "Sujet"), h("th", {}, "Profil"), h("th", {}, "Déposée le")), + ), + body, + ); + const inspector = h("aside", { class: "inspector", "aria-label": "Inspecteur", "data-testid": "inspector" }); + const notice = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "requests-status" }); + const element = h( + "section", + { class: "requests" }, + h("h1", {}, "Demandes d'enrôlement en attente"), + h("p", { class: "muted" }, "j/k : se déplacer · a : approuver · r : rejeter"), + notice, + h("div", { class: "split" }, table, inspector), + ); + + const render = (): void => { + replace( + body, + ...rows.map((r, i) => { + const tr = h( + "tr", + { "aria-selected": String(i === selected), "data-testid": `row-${r.transaction_id}` }, + h("td", {}, h("span", { class: "badge pending" }, "⏳ en attente")), + h("td", { class: "mono" }, r.transaction_id), + h("td", {}, r.subject_cn), + h("td", {}, r.profile), + h("td", { class: "mono" }, utc(r.created_at)), + ); + tr.addEventListener("click", () => { + selected = i; + render(); + }); + return tr; + }), + ); + if (rows.length === 0) { + replace(body, h("tr", {}, h("td", { colspan: "5", class: "muted" }, "Aucune demande en attente."))); + } + renderInspector(); + }; + + const renderInspector = (): void => { + const r = rows[selected]; + if (r === undefined) { + replace(inspector, h("p", { class: "muted" }, "Aucune demande sélectionnée.")); + return; + } + const approve = h("button", { type: "button", class: "primary", "data-testid": "approve" }, "Approuver la demande…"); + const reject = h("button", { type: "button", "data-testid": "reject" }, "Rejeter la demande…"); + approve.addEventListener("click", () => void decide(r, "approve")); + reject.addEventListener("click", () => void decide(r, "reject")); + replace( + inspector, + h("h2", {}, "Général"), + h( + "dl", + {}, + h("dt", {}, "Transaction"), + h("dd", { class: "mono" }, r.transaction_id), + h("dt", {}, "Sujet (CN)"), + h("dd", {}, r.subject_cn), + h("dt", {}, "Profil"), + h("dd", {}, r.profile), + h("dt", {}, "Déposée le"), + h("dd", { class: "mono" }, utc(r.created_at)), + ), + h("div", { class: "actions" }, approve, reject), + ); + }; + + const decide = async (r: EnrollmentRequest, kind: "approve" | "reject"): Promise => { + const comment = await askComment(kind); + if (comment === null) return; + const approving = kind === "approve"; + const ok = await sign({ + title: approving ? `Approbation de la demande ${r.transaction_id}` : `Rejet de la demande ${r.transaction_id}`, + summary: [ + ["Action", approving ? "approuver l'émission du certificat" : "rejeter définitivement la demande"], + ["Transaction", r.transaction_id], + ["Sujet (CN)", r.subject_cn], + ["Profil", r.profile], + ["Justification", comment === "" ? "—" : comment], + ], + action: { + action: approving ? "approve_request" : "reject_request", + transaction_id: r.transaction_id, + comment, + }, + route: `/api/v1/requests/${encodeURIComponent(r.transaction_id)}/${kind}`, + }); + if (ok) { + notice.textContent = approving ? `Demande ${r.transaction_id} approuvée.` : `Demande ${r.transaction_id} rejetée.`; + await load(); + onDecided(); + } + }; + + const load = async (): Promise => { + const reply = await call("GET", "/api/v1/requests?state=PENDING"); + if (reply.status !== 200 || !Array.isArray(reply.body)) { + notice.textContent = isError(reply.body) ? reply.body.message : "File indisponible."; + rows = []; + } else { + rows = reply.body; + } + selected = Math.min(selected, Math.max(rows.length - 1, 0)); + render(); + }; + + const onKey = (event: KeyboardEvent): void => { + const target = event.target as HTMLElement | null; + if (document.querySelector("dialog[open]") !== null) return; + if (target !== null && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; + const r = rows[selected]; + if (event.key === "j" || event.key === "ArrowDown") { + selected = Math.min(selected + 1, rows.length - 1); + render(); + } else if (event.key === "k" || event.key === "ArrowUp") { + selected = Math.max(selected - 1, 0); + render(); + } else if (event.key === "a" && r !== undefined) { + void decide(r, "approve"); + } else if (event.key === "r" && r !== undefined) { + void decide(r, "reject"); + } else { + return; + } + event.preventDefault(); + }; + window.addEventListener("keydown", onKey); + void load(); + return { element, dispose: () => window.removeEventListener("keydown", onKey) }; +} + +/// Justification de la décision : obligatoire pour un rejet (UI-UX §3.4), +/// facultative pour une approbation. `null` si l'opérateur renonce. +function askComment(kind: "approve" | "reject"): Promise { + return new Promise((resolve) => { + const required = kind === "reject"; + const input = h("textarea", { + id: "decision-comment", + rows: "3", + maxlength: "1000", + "data-testid": "comment", + ...(required ? { required: "" } : {}), + }); + 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", {}, kind === "reject" ? "Motif du rejet (obligatoire)" : "Commentaire (facultatif)"), + h("label", { for: "decision-comment" }, "Justification consignée au journal"), + input, + h("div", { class: "actions" }, cancel, next), + ); + const dialog = h("dialog", { class: "signature", "data-testid": "comment-dialog" }, form); + const finish = (value: string | null): void => { + dialog.close(); + dialog.remove(); + resolve(value); + }; + form.addEventListener("submit", (event) => { + event.preventDefault(); + const value = input.value.trim(); + if (required && value === "") { + input.focus(); + return; + } + finish(value); + }); + cancel.addEventListener("click", () => finish(null)); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + finish(null); + }); + document.body.append(dialog); + dialog.showModal(); + input.focus(); + }); +} diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts index 102ea22..2a088b7 100644 --- a/bin/ra-console/web/src/shell.ts +++ b/bin/ra-console/web/src/shell.ts @@ -1,11 +1,12 @@ // Le poste de travail une fois connecté (docs/UI-UX.md §2) : barre de // sécurité (environnement, identité et rôle relus sur le serveur), navigation // latérale avec les compteurs des files, zone de travail. Les écrans métier -// (demandes, révocation, quorum, audit) arrivent aux étapes 6b et suivantes. +// commencent par la file des demandes (6b) ; révocation, quorum et audit suivent. import { call, isError, type ConsoleInfo, type Me } from "./api"; import { banner } from "./banner"; import { h, replace } from "./dom"; +import { requestsView } from "./requests"; const ROLE_LABELS: Record = { auditeur: "auditeur", @@ -14,7 +15,7 @@ const ROLE_LABELS: Record = { admin: "administrateur", }; -export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogout: () => void): void { +export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogout: () => void): () => void { const logout = h("button", { type: "button", class: "quiet", "data-testid": "logout" }, "Se déconnecter"); logout.addEventListener("click", onLogout); const bar = h( @@ -36,14 +37,11 @@ export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogo { class: "sidebar", "aria-label": "Files de travail" }, h("ul", {}, h("li", {}, "Demandes RA ", requests), h("li", {}, "Quorum ", quorum)), ); - const work = h( - "main", - { class: "workspace", tabindex: "-1" }, - h("h1", {}, "Files de travail"), - h("p", { class: "muted" }, "Les écrans de décision arrivent avec les étapes suivantes."), - ); + const view = requestsView(() => void refreshCounts(requests, quorum)); + const work = h("main", { class: "workspace", tabindex: "-1" }, view.element); replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); void refreshCounts(requests, quorum); + return view.dispose; } async function refreshCounts(requests: HTMLElement, quorum: HTMLElement): Promise { diff --git a/bin/ra-console/web/src/sign.ts b/bin/ra-console/web/src/sign.ts new file mode 100644 index 0000000..79e0b56 --- /dev/null +++ b/bin/ra-console/web/src/sign.ts @@ -0,0 +1,146 @@ +// Cérémonie de signature d'une action (docs/UI-UX.md §3.1, docs/WEBUI.md §4). +// +// 1. La console demande à ca-server de figer l'action : le corps rendu est +// celui qui sera exécuté, affiché tel quel, avec son empreinte SHA-256. +// 2. L'opérateur signe avec sa clé FIDO2. +// 3. L'assertion est relayée ; en cas d'erreur, la modale reste ouverte et +// l'opérateur peut relancer sans ressaisir. +// +// La modale est un natif : focus piégé, Échap ferme — sauf pendant la +// cérémonie matérielle — et un clic extérieur ne la ferme jamais (§5.2). + +import { call, isError } from "./api"; +import { h, replace } from "./dom"; +import { groupedHash } from "./format"; +import { assert } from "./webauthn"; + +interface Issued { + challenge_id: string; + body: Record; + body_hash: string; + webauthn: Parameters[0]; +} + +export interface Signing { + /// Titre de la modale (« Approbation de la demande tx-… »). + title: string; + /// Résumé en clair, ligne par ligne, de ce qui va être signé. + summary: [string, string][]; + /// L'action à figer (forme d'oe_actions). + action: Record; + /// La route qui relaie l'assertion (ex. /api/v1/requests/{id}/approve). + route: string; +} + +const MESSAGES: Record = { + action_mismatch: "La signature ne correspond pas à cette action.", + already_used: "Cette signature a déjà servi : relancez.", + expired: "Le délai de signature est dépassé : relancez.", + denied: "Action refusée par l'autorité (rôle ou état de la demande).", + signature_rejected: "Signature refusée par l'autorité.", + unauthenticated: "Session expirée : reconnectez-vous.", +}; + +function explain(body: unknown, fallback: string): string { + if (isError(body)) return MESSAGES[body.error] ?? body.message; + return fallback; +} + +/// Ouvre la modale ; rend `true` si l'action a été signée et relayée avec succès. +export function sign(signing: Signing): Promise { + return new Promise((resolve) => { + let busy = false; + let done = false; + let issued: Issued | null = null; + + const status = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "sign-status" }); + const frozen = h("div", { class: "frozen", "data-testid": "frozen" }); + const signButton = h("button", { type: "button", class: "primary", "data-testid": "sign" }, "Signer avec ma clé FIDO2"); + const cancel = h("button", { type: "button", "data-testid": "sign-cancel" }, "Annuler"); + const summary = h( + "dl", + { class: "summary" }, + ...signing.summary.flatMap(([k, v]) => [h("dt", {}, k), h("dd", {}, v)]), + ); + const dialog = h( + "dialog", + { class: "signature", "aria-labelledby": "sign-title", "data-testid": "sign-dialog" }, + h("h2", { id: "sign-title" }, `🔑 ${signing.title}`), + summary, + frozen, + status, + h("div", { class: "actions" }, cancel, signButton), + ); + + const close = (result: boolean): void => { + dialog.close(); + dialog.remove(); + resolve(result); + }; + + const prepare = async (): Promise => { + signButton.disabled = true; + status.textContent = "Préparation de l'action par l'autorité…"; + const reply = await call("POST", "/api/v1/webauthn/challenge", signing.action); + if (reply.status !== 200 || reply.body === null || isError(reply.body)) { + status.textContent = explain(reply.body, "L'autorité n'a pas pu préparer l'action."); + return false; + } + issued = reply.body; + replace( + frozen, + h("p", { class: "muted" }, "Corps exact qui sera exécuté (figé par l'autorité) :"), + h("pre", { class: "mono", "data-testid": "frozen-body" }, JSON.stringify(issued.body, null, 2)), + h("p", { class: "muted" }, "Empreinte de la requête (SHA-256) :"), + h("p", { class: "mono hash", "data-testid": "body-hash" }, groupedHash(issued.body_hash)), + ); + status.textContent = ""; + signButton.disabled = false; + return true; + }; + + const ceremony = async (): Promise => { + if (issued === null && !(await prepare())) return; + const current = issued; + if (current === null) return; + busy = true; + signButton.disabled = true; + cancel.disabled = true; + status.textContent = "Touchez votre clé de sécurité matérielle…"; + try { + const assertion = await assert(current.webauthn); + const reply = await call("POST", signing.route, { challenge_id: current.challenge_id, assertion }); + if (reply.status === 200) { + done = true; + status.textContent = "✓ Action signée et exécutée."; + window.setTimeout(() => close(true), 800); + return; + } + // Un challenge consommé ou expiré ne resservira pas : on en redemande un. + issued = null; + status.textContent = explain(reply.body, "L'autorité a refusé l'action."); + } catch { + status.textContent = "La clé n'a pas répondu (annulation ou délai). Réessayez."; + } finally { + busy = false; + if (!done) { + signButton.disabled = false; + cancel.disabled = false; + } + } + }; + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + if (!busy && !done) close(false); + }); + cancel.addEventListener("click", () => { + if (!busy) close(false); + }); + signButton.addEventListener("click", () => void ceremony()); + + document.body.append(dialog); + dialog.showModal(); + void prepare().then(() => signButton.focus()); + }); +} diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css index 15b2bc1..e3c4755 100644 --- a/bin/ra-console/web/static/console.css +++ b/bin/ra-console/web/static/console.css @@ -210,3 +210,117 @@ button.quiet { color: var(--undeclared-text); border-bottom: 1px dashed var(--undeclared-text); } + +/* --- 6b : file des demandes, inspecteur, modale de signature (UI-UX §3.1, §5) --- */ + +.mono { + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.split { + display: grid; + grid-template-columns: 1fr minmax(420px, 34%); + gap: 16px; + align-items: start; +} +table.dense { + width: 100%; + border-collapse: collapse; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); +} +table.dense th { + position: sticky; + top: 0; + background: var(--bg-surface-elevated); + text-align: left; + font-size: 12px; + color: var(--text-muted); +} +table.dense th, +table.dense td { + padding: 8px 10px; + height: 38px; + border-bottom: 1px solid var(--border-subtle); +} +table.dense tr[aria-selected="true"] td { + background: var(--bg-surface-elevated); +} +table.dense tr[aria-selected="true"] td:first-child { + box-shadow: inset 3px 0 0 var(--border-focus); +} +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; +} +.badge.pending { + background: #451a03; + color: #fbbf24; + border: 1px dashed #d97706; +} +.inspector { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 6px; + padding: 16px; +} +.inspector h2, +dialog.signature h2 { + font-size: 16px; + margin: 0 0 12px; +} +dl { + display: grid; + grid-template-columns: max-content 1fr; + gap: 6px 16px; + margin: 0 0 16px; +} +dt { + color: var(--text-muted); +} +dd { + margin: 0; + overflow-wrap: anywhere; +} +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +dialog.signature { + width: min(640px, 92vw); + background: var(--bg-surface-elevated); + color: var(--text-main); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: 24px; +} +dialog.signature::backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} +dialog.signature textarea { + width: 100%; + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px; + margin: 8px 0 16px; +} +.frozen pre { + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 12px; + overflow: auto; + max-height: 240px; + font-size: 12px; +} +.hash { + font-size: 13px; + word-spacing: 0.4em; +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index 035ebd8..652738d 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -186,8 +186,15 @@ construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). - Connexion par nom et clé FIDO2, poste de travail (identité et rôle relus sur le serveur, compteurs des files), déconnexion, **verrouillage après 15 minutes d'inactivité** (avertissement à 14) : la session est révoquée côté serveur. -- Les écrans métier (décisions, révocation, quorum, audit) suivent (étapes 6b et - suivantes). +- **File des demandes (6b)** : tableau dense des demandes en attente, sélection au + clavier (`j`/`k`, `a` approuver, `r` rejeter), inspecteur latéral. Une décision passe + par une justification (**obligatoire pour un rejet**, garde d'interface seulement : + `ca-server` ne l'exige pas pour la voie signée), puis par la **modale de signature** + (`` natif) : elle affiche le corps que `ca-server` a figé, tel quel, et son + empreinte SHA-256, avant tout geste sur la clé. Une erreur laisse la modale ouverte ; + un challenge consommé ou expiré est redemandé au besoin. Échap annule, sauf pendant + la cérémonie matérielle. +- Révocation, salle de quorum et audit suivent (étapes 6c et suivantes). ## Variables d'environnement