use dioxus::prelude::*; use crate::components::page_header::PageHeader; use crate::infrastructure::onboarding::{ create_target, detect_target, fetch_applicable_scans, ArtifactInputDto, }; /// (value, label, one-line description) for the 9 target families. const TARGET_TYPES: &[(&str, &str, &str)] = &[ ("web_app", "Web Application", "Front end + server"), ("backend_service", "Backend / API", "REST, GraphQL, gRPC"), ("desktop_app", "Desktop App", "Windows / macOS / Linux"), ("android_app", "Android App", "APK / AAB"), ("ios_app", "iOS App", "IPA"), ( "firmware_bare_metal", "Firmware — bare metal", "No operating system", ), ("firmware_rtos", "Firmware — RTOS", "Zephyr, FreeRTOS, ..."), ( "embedded_linux_yocto", "Embedded Linux / Yocto", "BSP + image", ), ("plc_sps", "PLC / SPS", "IEC 61131-3"), ]; /// (value, label) for the artifact kinds a user can attach. const ARTIFACT_KINDS: &[(&str, &str)] = &[ ("git_repo", "Git repository"), ("source_archive", "Source archive (zip)"), ("firmware_image", "Firmware image"), ("mobile_package", "Mobile package (APK/IPA)"), ("container_image", "Container image"), ("live_url", "Live URL"), ("plc_project", "PLC project"), ("plaintext_description", "Description (text)"), ]; const STEP_LABELS: &[&str] = &["Target type", "Artifacts", "Review", "Done"]; /// One row in the applicable-scans list on the success step. #[component] fn ScanRow(scan: serde_json::Value) -> Element { let name = scan .get("scan") .and_then(|v| v.as_str()) .unwrap_or("?") .to_string(); let rationale = scan .get("rationale") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let blocked = scan .get("blocked_reason") .and_then(|v| v.as_str()) .map(String::from); let default_on = scan .get("default_on") .and_then(|v| v.as_bool()) .unwrap_or(false); let badge_class = if blocked.is_some() { "badge badge-info" } else if default_on { "badge badge-success" } else { "badge" }; rsx! { div { style: "display: flex; gap: 8px; align-items: center; padding: 6px 0;", span { class: "{badge_class}", "{name}" } span { style: "opacity: 0.8;", "{rationale}" } if let Some(b) = blocked { span { style: "opacity: 0.6; font-style: italic;", "— {b}" } } } } } fn kind_label(kind: &str) -> &str { ARTIFACT_KINDS .iter() .find(|(v, _)| *v == kind) .map(|(_, l)| *l) .unwrap_or(kind) } fn type_label(value: &str) -> &str { TARGET_TYPES .iter() .find(|(v, _, _)| *v == value) .map(|(_, l, _)| *l) .unwrap_or(value) } #[component] pub fn OnboardingPage() -> Element { let mut step = use_signal(|| 0usize); let mut name = use_signal(String::new); let mut target_type = use_signal(String::new); let mut description = use_signal(String::new); let mut artifacts = use_signal(Vec::::new); // "Add artifact" mini-form. let mut new_kind = use_signal(|| "git_repo".to_string()); let mut new_source = use_signal(String::new); let mut new_branch = use_signal(|| "main".to_string()); // Create + result state. let mut creating = use_signal(|| false); let mut error = use_signal(|| Option::::None); let mut scans = use_signal(Vec::::new); let mut suggested = use_signal(|| Option::::None); let step_now = step(); let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty(); let has_artifacts = !artifacts().is_empty(); rsx! { PageHeader { title: "Onboard a target", description: "Add a target, attach its artifacts, and see which scans apply.", } // Stepper. div { class: "wizard-steps", for (i, label) in STEP_LABELS.iter().enumerate() { div { class: if i == step_now { "wizard-step wizard-step-active" } else { "wizard-step" }, span { class: "wizard-step-dot", "{i + 1}" } span { class: "wizard-step-label", "{label}" } } } } if let Some(err) = error() { div { class: "card", style: "border-color: var(--danger, #d33); margin-bottom: 12px;", div { class: "card-header", "Error" } div { style: "padding: 12px;", "{err}" } } } div { class: "card", // ---- Step 0: target type + name ---- if step_now == 0 { div { class: "card-header", "What kind of software is this?" } div { style: "padding: 16px;", div { class: "form-group", label { "Name" } input { r#type: "text", placeholder: "acme-web", value: "{name}", oninput: move |e| name.set(e.value()), } } div { style: "display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 12px;", for (value, tlabel, tdesc) in TARGET_TYPES.iter().copied() { div { class: "card", style: if target_type() == value { "padding: 12px; cursor: pointer; border: 2px solid var(--accent, #3b82f6);" } else { "padding: 12px; cursor: pointer;" }, onclick: move |_| target_type.set(value.to_string()), div { style: "font-weight: 600;", "{tlabel}" } div { style: "font-size: 0.85em; opacity: 0.7;", "{tdesc}" } } } } } } // ---- Step 1: artifacts ---- if step_now == 1 { div { class: "card-header", "Attach artifacts" } div { style: "padding: 16px;", div { style: "display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;", div { class: "form-group", style: "margin: 0;", label { "Kind" } select { value: "{new_kind}", oninput: move |e| new_kind.set(e.value()), for (value, klabel) in ARTIFACT_KINDS.iter().copied() { option { value: "{value}", "{klabel}" } } } } div { class: "form-group", style: "margin: 0; flex: 1; min-width: 240px;", label { "Reference (URL / path / text)" } input { r#type: "text", placeholder: "https://git.example.com/acme.git", value: "{new_source}", oninput: move |e| new_source.set(e.value()), } } if new_kind() == "git_repo" { div { class: "form-group", style: "margin: 0;", label { "Branch" } input { r#type: "text", value: "{new_branch}", oninput: move |e| new_branch.set(e.value()), } } } button { class: "btn btn-secondary", onclick: move |_| { let kind = new_kind(); if !new_source().trim().is_empty() { let branch = if kind == "git_repo" { Some(new_branch()) } else { None }; artifacts.write().push(ArtifactInputDto { kind, source_ref: new_source(), branch, plc_format: None, }); new_source.set(String::new()); } }, "+ Add" } } div { style: "margin-top: 16px;", if has_artifacts { for (i, a) in artifacts().iter().enumerate() { div { style: "display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border: 1px solid var(--border, #333); border-radius: 6px; margin-bottom: 6px;", span { span { style: "opacity: 0.7;", "{kind_label(&a.kind)}: " } "{a.source_ref}" } button { class: "btn btn-ghost-danger btn-sm", onclick: move |_| { artifacts.write().remove(i); }, "Remove" } } } } else { div { style: "opacity: 0.6;", "No artifacts yet. Add at least one." } } } } } // ---- Step 2: review + create ---- if step_now == 2 { div { class: "card-header", "Review" } div { style: "padding: 16px;", div { class: "wizard-summary", div { strong { "Name: " } "{name()}" } div { strong { "Type: " } "{type_label(&target_type())}" } div { strong { "Artifacts:" } } ul { for a in artifacts() { li { "{kind_label(&a.kind)}: {a.source_ref}" } } } } div { style: "margin-top: 12px; opacity: 0.7; font-size: 0.9em;", "The applicable scans (SAST / DAST / firmware / PLC) are shown after the target is created." } } } // ---- Step 3: created ---- if step_now == 3 { div { class: "card-header", "Target onboarded" } div { style: "padding: 16px;", p { strong { "{name()}" } " was created." if let Some(s) = suggested() { span { " Suggested type from detection: " strong { "{type_label(&s)}" } "." } } } h4 { style: "margin-top: 16px;", "Applicable scans" } if scans().is_empty() { div { style: "opacity: 0.6;", "No scans available (no code / URL / firmware artifact present)." } } else { for s in scans() { ScanRow { scan: s } } } div { style: "margin-top: 16px;", button { class: "btn btn-secondary", onclick: move |_| { step.set(0); name.set(String::new()); target_type.set(String::new()); description.set(String::new()); artifacts.write().clear(); scans.write().clear(); suggested.set(None); error.set(None); }, "Onboard another" } } } } } // ---- Footer navigation ---- if step_now < 3 { div { style: "display: flex; justify-content: space-between; margin-top: 16px;", button { class: "btn btn-back", disabled: step_now == 0, onclick: move |_| { if step() > 0 { step.set(step() - 1); } }, "Back" } if step_now < 2 { button { class: "btn btn-primary", disabled: (step_now == 0 && !can_advance_type) || (step_now == 1 && !has_artifacts), onclick: move |_| step.set(step() + 1), "Next" } } else { button { class: "btn btn-primary", disabled: creating(), onclick: move |_| { let n = name(); let tt = target_type(); let desc = description(); let arts = artifacts(); let d = if desc.trim().is_empty() { None } else { Some(desc) }; creating.set(true); error.set(None); spawn(async move { match create_target(n, tt, d, arts).await { Ok(resp) => { let id = resp .data .get("_id") .and_then(|o| o.get("$oid")) .and_then(|s| s.as_str()) .map(String::from); if let Some(id) = id { if let Ok(sc) = fetch_applicable_scans(id.clone()).await { scans.set(sc.data.scans); } if let Ok(det) = detect_target(id).await { suggested.set( det.data .get("classification") .and_then(|c| c.get("suggested")) .and_then(|s| s.as_str()) .map(String::from), ); } } step.set(3); } Err(e) => error.set(Some(e.to_string())), } creating.set(false); }); }, if creating() { "Creating..." } else { "Create target" } } } } } } }