Skip to main content

greentic_setup/ui/
mod.rs

1//! Web-based setup UI server.
2//!
3//! Launches an Axum HTTP server on a random port, opens the browser, and serves
4//! a single-page app that drives the setup wizard through the same FormSpec
5//! infrastructure as the terminal wizard.
6
7mod assets;
8
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use anyhow::Result;
15use axum::extract::State;
16use axum::http::header;
17use axum::response::IntoResponse;
18use axum::routing::{get, post};
19use axum::{Json, Router};
20use greentic_secrets_lib::{ApplyOptions, SecretFormat, SeedDoc, SeedEntry, SeedValue, apply_seed};
21use serde::{Deserialize, Serialize};
22use serde_json::{Map as JsonMap, Value};
23use tokio::sync::broadcast;
24
25use crate::cli_i18n::CliI18n;
26use crate::engine::{SetupConfig, SetupRequest};
27use crate::plan::TenantSelection;
28use crate::platform_setup::StaticRoutesPolicy;
29use crate::qa::wizard;
30use crate::{SetupEngine, SetupMode, discovery, setup_to_formspec};
31
32use crate::qa::shared_questions::HIDDEN_FROM_PROMPTS;
33
34// ── Types ──
35
36struct UiState {
37    bundle_path: PathBuf,
38    tenant: String,
39    team: Option<String>,
40    env: String,
41    #[allow(dead_code)]
42    advanced: bool,
43    locale: Option<String>,
44    /// Pre-loaded answers from `--answers` file, keyed by provider_id.
45    prefill_answers: Option<JsonMap<String, Value>>,
46    /// When true the tenant/env came from an answers file and should not be
47    /// overridden by bundle auto-detection.
48    scope_from_answers: bool,
49    shutdown_tx: broadcast::Sender<()>,
50    #[allow(dead_code)]
51    result: Mutex<Option<ExecutionResult>>,
52    /// The actual port this server is bound to (known before `UiState` is built).
53    /// Used to construct the embedded OAuth callback `redirect_uri`.
54    self_port: u16,
55    /// In-flight OAuth authorization-code exchanges, keyed by `state` token.
56    oauth_pending: Mutex<HashMap<String, OauthPending>>,
57    /// Connected provider keys (`"{provider}:{env}:{tenant}"`).
58    oauth_connected: Mutex<HashSet<String>>,
59}
60
61/// Server-side data for an in-flight OAuth authorization-code exchange.
62///
63/// Stored when `/api/oauth/start` is called and consumed by the embedded
64/// `/api/oauth/callback` handler. The `client_secret` is kept here and never
65/// returned to the browser.
66struct OauthPending {
67    provider: String,
68    pack_provider: String,
69    env: String,
70    tenant: String,
71    team: Option<String>,
72    token_url: String,
73    client_id: String,
74    client_secret: String,
75    redirect_uri: String,
76    pkce_verifier: String,
77}
78
79/// Generate a PKCE (RFC 7636) verifier and its S256 code challenge.
80///
81/// HubSpot (and many providers) require PKCE for authorization-code flows. The
82/// verifier is kept server-side in [`OauthPending`] and replayed at the token
83/// exchange; only the challenge is sent on the authorize redirect.
84fn generate_pkce() -> (String, String) {
85    use base64::Engine;
86    use sha2::Digest;
87    let bytes: [u8; 32] = core::array::from_fn(|_| rand::random::<u8>());
88    let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
89    let digest = sha2::Sha256::digest(verifier.as_bytes());
90    let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
91    (verifier, challenge)
92}
93
94#[derive(Serialize)]
95#[allow(dead_code)]
96struct ProvidersResponse {
97    bundle_path: String,
98    providers: Vec<ProviderInfo>,
99    provider_forms: Vec<ProviderForm>,
100    shared_questions: Vec<QuestionInfo>,
101}
102
103#[derive(Serialize)]
104struct ProviderInfo {
105    provider_id: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    display_name: Option<String>,
108    domain: String,
109    question_count: usize,
110}
111
112#[derive(Serialize)]
113struct ProviderForm {
114    provider_id: String,
115    title: String,
116    questions: Vec<QuestionInfo>,
117}
118
119#[derive(Serialize, Clone)]
120struct QuestionInfo {
121    id: String,
122    title: String,
123    kind: String,
124    required: bool,
125    secret: bool,
126    default_value: Option<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    saved_value: Option<String>,
129    help: Option<String>,
130    choices: Option<Vec<String>>,
131    visible_if: Option<VisibleIfInfo>,
132    placeholder: Option<String>,
133    group: Option<String>,
134    docs_url: Option<String>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    widget: Option<String>,
137}
138
139#[derive(Serialize, Clone)]
140struct VisibleIfInfo {
141    field: String,
142    eq: Option<String>,
143}
144
145/// Extra fields from setup.yaml not in FormSpec.
146struct SetupQuestionExtras {
147    placeholder: Option<String>,
148    group: Option<String>,
149    docs_url: Option<String>,
150    widget: Option<String>,
151}
152
153#[derive(Deserialize)]
154struct ExecuteRequest {
155    answers: JsonMap<String, Value>,
156    #[serde(default)]
157    tenant: Option<String>,
158    #[serde(default)]
159    team: Option<String>,
160    #[serde(default)]
161    env: Option<String>,
162    #[serde(default)]
163    tunnel: Option<String>,
164}
165
166#[derive(Deserialize)]
167struct DraftSaveRequest {
168    answers: JsonMap<String, Value>,
169    tenant: String,
170    #[serde(default)]
171    team: Option<String>,
172    env: String,
173}
174
175#[derive(Serialize)]
176struct ScopeResponse {
177    tenant: String,
178    team: Option<String>,
179    env: String,
180    detected_tenant: Option<String>,
181}
182
183#[derive(Serialize, Clone)]
184struct ExecutionResult {
185    success: bool,
186    stdout: String,
187    stderr: String,
188    manual_steps: Vec<crate::webhook::ProviderInstruction>,
189}
190
191// ── Public API ──
192
193/// Launch the setup UI server and open in browser.
194///
195/// When `prefill_answers` is provided (from `--answers` file), the values are
196/// injected into the UI as pre-filled form values so the user can review and
197/// edit before executing.
198#[allow(clippy::too_many_arguments)]
199pub async fn launch(
200    bundle_path: &Path,
201    tenant: &str,
202    team: Option<&str>,
203    env: &str,
204    advanced: bool,
205    locale: Option<&str>,
206    prefill_answers: Option<JsonMap<String, Value>>,
207    scope_from_answers: bool,
208    port: Option<u16>,
209) -> Result<()> {
210    let (shutdown_tx, _) = broadcast::channel::<()>(1);
211
212    // Bind first so the embedded OAuth callback can know its own URL: use the
213    // fixed port when provided, otherwise a random free port.
214    let bind_addr = format!("127.0.0.1:{}", port.unwrap_or(0));
215    let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
216    let bound_port = listener.local_addr()?.port();
217
218    let state = std::sync::Arc::new(UiState {
219        bundle_path: bundle_path.to_path_buf(),
220        tenant: tenant.to_string(),
221        team: team.map(String::from),
222        env: env.to_string(),
223        advanced,
224        locale: locale.map(String::from),
225        prefill_answers,
226        scope_from_answers,
227        shutdown_tx: shutdown_tx.clone(),
228        result: Mutex::new(None),
229        self_port: bound_port,
230        oauth_pending: Mutex::new(HashMap::new()),
231        oauth_connected: Mutex::new(HashSet::new()),
232    });
233
234    let router = build_router(state.clone());
235
236    let url = format!("http://127.0.0.1:{bound_port}");
237
238    eprintln!("Setup UI started at: {url}");
239    let _ = open::that(&url);
240
241    let mut shutdown_rx = shutdown_tx.subscribe();
242    axum::serve(listener, router)
243        .with_graceful_shutdown(async move {
244            let _ = shutdown_rx.recv().await;
245        })
246        .await?;
247
248    Ok(())
249}
250
251fn build_router(state: std::sync::Arc<UiState>) -> Router {
252    Router::new()
253        .route("/", get(serve_index))
254        .route("/app.js", get(serve_js))
255        .route("/style.css", get(serve_css))
256        .route("/api/locales", get(get_locales))
257        .route("/api/scope", get(get_scope))
258        .route("/api/existing-scopes", get(get_existing_scopes))
259        .route("/api/providers", get(get_providers))
260        .route("/api/draft", post(post_draft))
261        .route("/api/execute", post(post_execute))
262        .route("/api/export", post(post_export))
263        .route("/api/decrypt", post(post_decrypt))
264        .route("/api/oauth/start", post(post_oauth_start))
265        .route("/api/oauth/callback", get(get_oauth_callback))
266        .route("/api/oauth/status", get(get_oauth_status))
267        .route("/api/shutdown", post(post_shutdown))
268        .with_state(state)
269}
270
271// ── Static assets ──
272
273async fn serve_index() -> impl IntoResponse {
274    (
275        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
276        assets::INDEX_HTML,
277    )
278}
279
280async fn serve_js() -> impl IntoResponse {
281    (
282        [(
283            header::CONTENT_TYPE,
284            "application/javascript; charset=utf-8",
285        )],
286        assets::APP_JS,
287    )
288}
289
290async fn serve_css() -> impl IntoResponse {
291    (
292        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
293        assets::STYLE_CSS,
294    )
295}
296
297// ── API handlers ──
298
299/// Well-known locales with display labels.
300const LOCALE_OPTIONS: &[(&str, &str)] = &[
301    ("en", "English"),
302    ("id", "Bahasa Indonesia"),
303    ("ja", "日本語"),
304    ("zh", "中文"),
305    ("ko", "한국어"),
306    ("es", "Español"),
307    ("fr", "Français"),
308    ("de", "Deutsch"),
309    ("pt", "Português"),
310    ("ru", "Русский"),
311    ("ar", "العربية"),
312    ("th", "ไทย"),
313    ("vi", "Tiếng Việt"),
314    ("tr", "Türkçe"),
315    ("it", "Italiano"),
316    ("nl", "Nederlands"),
317    ("pl", "Polski"),
318    ("sv", "Svenska"),
319    ("hi", "हिन्दी"),
320    ("ms", "Bahasa Melayu"),
321];
322
323async fn get_locales(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
324    let current = state.locale.as_deref().unwrap_or("en");
325    let locales: Vec<Value> = LOCALE_OPTIONS
326        .iter()
327        .map(|(code, label)| {
328            serde_json::json!({
329                "code": code,
330                "label": label,
331                "selected": *code == current,
332            })
333        })
334        .collect();
335    Json(serde_json::json!({ "locales": locales, "current": current }))
336}
337
338#[derive(Deserialize)]
339struct ProviderQuery {
340    locale: Option<String>,
341}
342
343async fn get_scope(State(state): State<std::sync::Arc<UiState>>) -> Json<ScopeResponse> {
344    let bundle_path = &state.bundle_path;
345    let cli_tenant = &state.tenant;
346    let cli_env = &state.env;
347
348    // Detect tenant from the bundle's tenants/ directory for informational display.
349    let detected_tenant = detect_tenant_from_bundle(bundle_path);
350
351    // When the scope was explicitly provided via --answers, use it as-is
352    // without overriding with bundle detection.
353    let effective_tenant = if state.scope_from_answers {
354        cli_tenant.clone()
355    } else if cli_tenant == "demo" {
356        // Apply same resolution logic as resolve_setup_scope_with_bundle:
357        // if CLI tenant is the default "demo" and we detect a tenant from
358        // the bundle, use it.
359        detected_tenant
360            .clone()
361            .unwrap_or_else(|| cli_tenant.clone())
362    } else {
363        cli_tenant.clone()
364    };
365
366    Json(ScopeResponse {
367        tenant: effective_tenant,
368        team: state.team.clone(),
369        env: cli_env.clone(),
370        detected_tenant,
371    })
372}
373
374/// Detect tenant from the bundle's `tenants/` directory.
375fn detect_tenant_from_bundle(bundle_dir: &Path) -> Option<String> {
376    let tenants_dir = bundle_dir.join("tenants");
377    let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
378        .ok()?
379        .filter_map(|e| e.ok())
380        .filter(|e| e.path().is_dir())
381        .filter_map(|e| e.file_name().into_string().ok())
382        .collect();
383
384    match entries.len() {
385        0 => None,
386        1 => Some(entries[0].clone()),
387        _ => entries
388            .iter()
389            .find(|t| t.as_str() != "demo")
390            .cloned()
391            .or_else(|| entries.first().cloned()),
392    }
393}
394
395/// Scan the bundle for previously configured scopes.
396///
397/// Reads `state/config/*/setup-answers.json` for provider answers and
398/// probes the dev secrets store with detected tenants to reconstruct
399/// existing scope configurations.
400async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
401    let bundle_path = &state.bundle_path;
402
403    // 1. Detect tenants from tenants/ directory
404    let tenants = {
405        let mut t = Vec::new();
406        let tenants_dir = bundle_path.join("tenants");
407        if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
408            for entry in entries.flatten() {
409                if entry.path().is_dir()
410                    && let Some(name) = entry.file_name().to_str()
411                {
412                    t.push(name.to_string());
413                }
414            }
415        }
416        if t.is_empty() {
417            t.push(state.tenant.clone());
418        }
419        t.sort();
420        t
421    };
422
423    // 2. Read provider answers from state/config/*/setup-answers.json
424    let config_dir = bundle_path.join("state").join("config");
425    let mut provider_answers: JsonMap<String, Value> = JsonMap::new();
426    if let Ok(entries) = std::fs::read_dir(&config_dir) {
427        for entry in entries.flatten() {
428            if !entry.path().is_dir() {
429                continue;
430            }
431            let provider_id = entry.file_name().to_string_lossy().to_string();
432            let answers_file = entry.path().join("setup-answers.json");
433            if let Ok(content) = std::fs::read_to_string(&answers_file)
434                && let Ok(parsed) = serde_json::from_str::<Value>(&content)
435            {
436                provider_answers.insert(provider_id, parsed);
437            }
438        }
439    }
440
441    // 3. For each tenant, probe secrets store to see if secrets exist
442    let discovered = discovery::discover(bundle_path).ok();
443    let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
444        .iter()
445        .flat_map(|d| d.setup_targets())
446        .filter_map(|p| {
447            setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id).map(|fs| {
448                wizard::ProviderFormSpec {
449                    provider_id: p.provider_id.clone(),
450                    form_spec: fs,
451                }
452            })
453        })
454        .collect();
455
456    let envs_to_probe = ["dev", "local"];
457    let mut scopes = Vec::new();
458
459    for tenant in &tenants {
460        for env in &envs_to_probe {
461            let saved =
462                load_saved_secrets(bundle_path, env, tenant, None, &provider_form_specs).await;
463
464            if saved.is_empty() {
465                continue;
466            }
467
468            // Merge saved secrets with file-based answers
469            let mut merged_answers = JsonMap::new();
470            for (pid, file_ans) in &provider_answers {
471                merged_answers.insert(pid.clone(), file_ans.clone());
472            }
473            // Overlay saved secrets into answers
474            for (pid, secrets) in &saved {
475                let entry = merged_answers
476                    .entry(pid.clone())
477                    .or_insert_with(|| Value::Object(JsonMap::new()));
478                if let Some(obj) = entry.as_object_mut() {
479                    for (k, v) in secrets {
480                        obj.insert(k.clone(), Value::String(v.clone()));
481                    }
482                }
483            }
484
485            scopes.push(serde_json::json!({
486                "tenant": tenant,
487                "env": env,
488                "team": null,
489                "answers": merged_answers,
490                "providers_done": saved.keys().collect::<Vec<_>>(),
491            }));
492            break; // found secrets for this tenant, skip other envs
493        }
494    }
495
496    Json(serde_json::json!({ "scopes": scopes }))
497}
498
499async fn get_providers(
500    State(state): State<std::sync::Arc<UiState>>,
501    axum::extract::Query(query): axum::extract::Query<ProviderQuery>,
502) -> Json<Value> {
503    let bundle_path = &state.bundle_path;
504
505    // Use query locale override, fall back to CLI locale
506    let locale = query.locale.as_deref().or(state.locale.as_deref());
507
508    // Load i18n strings for the UI
509    let i18n = CliI18n::from_request(locale)
510        .unwrap_or_else(|_| CliI18n::from_request(Some("en")).expect("en locale must exist"));
511    let ui_strings = i18n.keys_with_prefix("ui.");
512
513    let discovered = match discovery::discover(bundle_path) {
514        Ok(d) => d,
515        Err(e) => {
516            return Json(serde_json::json!({
517                "bundle_path": bundle_path.display().to_string(),
518                "providers": [],
519                "provider_forms": [],
520                "shared_questions": [],
521                "i18n": ui_strings,
522                "error": e.to_string(),
523            }));
524        }
525    };
526
527    let setup_targets = discovered.setup_targets();
528
529    let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
530        .iter()
531        .filter_map(|provider| {
532            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
533                |form_spec| wizard::ProviderFormSpec {
534                    provider_id: provider.provider_id.clone(),
535                    form_spec,
536                },
537            )
538        })
539        .collect();
540
541    // Detect shared questions (saved values injected after secrets are loaded below)
542    let shared_question_specs = if provider_form_specs.len() > 1 {
543        wizard::collect_shared_questions(&provider_form_specs)
544            .shared_questions
545            .clone()
546    } else {
547        vec![]
548    };
549
550    let providers: Vec<ProviderInfo> = setup_targets
551        .iter()
552        .map(|p| {
553            let form = setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id);
554            ProviderInfo {
555                provider_id: p.provider_id.clone(),
556                display_name: p.display_name.clone(),
557                domain: p.domain.clone(),
558                question_count: form.as_ref().map(|f| f.questions.len()).unwrap_or(0),
559            }
560        })
561        .collect();
562
563    // Build lookup maps for extra fields (placeholder, group, docs_url) from setup.yaml
564    let mut extras_by_provider: std::collections::HashMap<
565        String,
566        std::collections::HashMap<String, SetupQuestionExtras>,
567    > = std::collections::HashMap::new();
568    for provider in &setup_targets {
569        if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path) {
570            let mut map = std::collections::HashMap::new();
571            for q in &spec.questions {
572                map.insert(
573                    q.name.clone(),
574                    SetupQuestionExtras {
575                        placeholder: q.placeholder.clone(),
576                        group: q.group.clone(),
577                        docs_url: q.docs_url.clone(),
578                        widget: if q.kind == "oauth_connect" {
579                            Some("oauth_connect".to_string())
580                        } else {
581                            None
582                        },
583                    },
584                );
585            }
586            extras_by_provider.insert(provider.provider_id.clone(), map);
587        }
588    }
589
590    // Load saved secrets from dev store for auto-fill
591    let saved_secrets = load_saved_secrets(
592        bundle_path,
593        &state.env,
594        &state.tenant,
595        state.team.as_deref(),
596        &provider_form_specs,
597    )
598    .await;
599
600    // Build per-provider prefill map from --answers file (overrides saved secrets)
601    let prefill = &state.prefill_answers;
602
603    // Inject saved values into shared questions (pick from first provider that has the value)
604    // Answers from --answers file take priority over saved secrets.
605    // Filter out questions that are auto-injected by the operator (e.g. public_base_url).
606    let shared_questions: Vec<QuestionInfo> = shared_question_specs
607        .iter()
608        .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
609        .map(|q| {
610            let mut info = form_question_to_info(q, Some(&i18n));
611            // First try --answers prefill (check all providers for the shared question)
612            let mut found = false;
613            if let Some(answers) = prefill {
614                for pfs in &provider_form_specs {
615                    if let Some(provider_answers) =
616                        answers.get(&pfs.provider_id).and_then(|v| v.as_object())
617                        && let Some(val) = provider_answers
618                            .get(&q.id)
619                            .and_then(value_as_nonempty_string)
620                    {
621                        info.saved_value = Some(val);
622                        found = true;
623                        break;
624                    }
625                }
626            }
627            // Fall back to saved secrets
628            if !found {
629                for secrets in saved_secrets.values() {
630                    if let Some(val) = secrets.get(&q.id) {
631                        info.saved_value = Some(val.clone());
632                        break;
633                    }
634                }
635            }
636            info
637        })
638        .collect();
639
640    let provider_forms: Vec<ProviderForm> = provider_form_specs
641        .iter()
642        .map(|pfs| {
643            let extras = extras_by_provider.get(&pfs.provider_id);
644            let saved = saved_secrets.get(&pfs.provider_id);
645            let answers = prefill
646                .as_ref()
647                .and_then(|a| a.get(&pfs.provider_id))
648                .and_then(|v| v.as_object());
649            ProviderForm {
650                provider_id: pfs.provider_id.clone(),
651                title: pfs.form_spec.title.clone(),
652                questions: pfs
653                    .form_spec
654                    .questions
655                    .iter()
656                    .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
657                    .map(|q| {
658                        let mut info = form_question_to_info(q, Some(&i18n));
659                        if let Some(ext) = extras.and_then(|m| m.get(&q.id)) {
660                            if info.placeholder.is_none() {
661                                info.placeholder = ext.placeholder.clone();
662                            }
663                            info.group = ext.group.clone();
664                            info.docs_url = ext.docs_url.clone();
665                            info.widget = ext.widget.clone();
666                        }
667                        // --answers prefill takes priority over saved secrets
668                        if let Some(val) = answers
669                            .and_then(|m| m.get(&q.id))
670                            .and_then(value_as_nonempty_string)
671                        {
672                            info.saved_value = Some(val);
673                        } else if let Some(val) = saved.and_then(|m| m.get(&q.id)) {
674                            info.saved_value = Some(val.clone());
675                        }
676                        info
677                    })
678                    .collect(),
679            }
680        })
681        .collect();
682
683    Json(serde_json::json!({
684        "bundle_path": bundle_path.display().to_string(),
685        "providers": providers,
686        "provider_forms": provider_forms,
687        "shared_questions": shared_questions,
688        "i18n": ui_strings,
689    }))
690}
691
692async fn post_execute(
693    State(state): State<std::sync::Arc<UiState>>,
694    Json(req): Json<ExecuteRequest>,
695) -> Json<ExecutionResult> {
696    let bundle_path = state.bundle_path.clone();
697    // Use scope from UI request if provided, otherwise fall back to CLI defaults
698    let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
699    let team = req.team.or_else(|| state.team.clone());
700    let env = req.env.unwrap_or_else(|| state.env.clone());
701    let answers = req.answers;
702
703    // Persist tunnel config from the UI selection.
704    if let Some(mode) = req.tunnel.as_deref() {
705        let tunnel = crate::platform_setup::TunnelAnswers {
706            mode: Some(mode.to_string()),
707        };
708        let _ = crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel);
709    }
710
711    let result = tokio::task::spawn_blocking(move || {
712        execute_setup(&bundle_path, &tenant, team.as_deref(), &env, answers)
713    })
714    .await
715    .unwrap_or_else(|e| ExecutionResult {
716        success: false,
717        stdout: String::new(),
718        stderr: format!("Task panicked: {e}"),
719        manual_steps: vec![],
720    });
721
722    *state.result.lock().unwrap() = Some(result.clone());
723    Json(result)
724}
725
726async fn post_draft(
727    State(state): State<std::sync::Arc<UiState>>,
728    Json(req): Json<DraftSaveRequest>,
729) -> Json<Value> {
730    match persist_ui_draft(
731        &state.bundle_path,
732        &req.tenant,
733        req.team.as_deref(),
734        &req.env,
735        &req.answers,
736    )
737    .await
738    {
739        Ok(persisted) => Json(serde_json::json!({
740            "ok": true,
741            "persisted": persisted,
742        })),
743        Err(err) => Json(serde_json::json!({
744            "ok": false,
745            "error": err.to_string(),
746        })),
747    }
748}
749
750#[derive(Deserialize)]
751struct ExportRequest {
752    scopes: Vec<ExportScope>,
753    #[serde(default)]
754    key: Option<String>,
755}
756
757#[derive(Deserialize)]
758struct ExportScope {
759    tenant: String,
760    #[serde(default)]
761    team: Option<String>,
762    env: String,
763    answers: JsonMap<String, Value>,
764}
765
766async fn post_export(
767    State(state): State<std::sync::Arc<UiState>>,
768    Json(req): Json<ExportRequest>,
769) -> Json<Value> {
770    let bundle_path = state.bundle_path.clone();
771
772    // Discover packs to identify secret fields for encryption
773    let discovered = discovery::discover(&bundle_path).ok();
774    let secret_fields: std::collections::HashSet<String> = discovered
775        .iter()
776        .flat_map(|d| d.setup_targets())
777        .filter_map(|p| setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id))
778        .flat_map(|spec| spec.questions.into_iter())
779        .filter(|q| q.secret)
780        .map(|q| q.id)
781        .collect();
782
783    let mut scopes_json = Vec::new();
784    for scope in &req.scopes {
785        let mut setup_answers = JsonMap::new();
786        for (provider_id, provider_answers) in &scope.answers {
787            let mut encrypted_answers = JsonMap::new();
788            if let Some(obj) = provider_answers.as_object() {
789                for (field, value) in obj {
790                    if secret_fields.contains(field) && req.key.is_some() {
791                        let key = req.key.as_deref().unwrap();
792                        match crate::answers_crypto::encrypt_value(value, key) {
793                            Ok(enc) => {
794                                encrypted_answers.insert(field.clone(), enc);
795                            }
796                            Err(_) => {
797                                encrypted_answers.insert(field.clone(), value.clone());
798                            }
799                        }
800                    } else {
801                        encrypted_answers.insert(field.clone(), value.clone());
802                    }
803                }
804            }
805            setup_answers.insert(provider_id.clone(), Value::Object(encrypted_answers));
806        }
807        scopes_json.push(serde_json::json!({
808            "tenant": scope.tenant,
809            "team": scope.team,
810            "env": scope.env,
811            "setup_answers": setup_answers,
812        }));
813    }
814
815    // Single scope → flat format (compatible with --answers)
816    // Multiple scopes → array format
817    let doc = if scopes_json.len() == 1 {
818        let mut single = scopes_json.into_iter().next().unwrap();
819        if let Some(obj) = single.as_object_mut() {
820            obj.insert(
821                "greentic_setup_version".to_string(),
822                Value::String("1.0.0".to_string()),
823            );
824            obj.insert(
825                "bundle_source".to_string(),
826                Value::String(bundle_path.display().to_string()),
827            );
828        }
829        single
830    } else {
831        serde_json::json!({
832            "greentic_setup_version": "1.0.0",
833            "bundle_source": bundle_path.display().to_string(),
834            "scopes": scopes_json,
835        })
836    };
837
838    Json(doc)
839}
840
841#[derive(Deserialize)]
842struct DecryptRequest {
843    doc: Value,
844    key: String,
845}
846
847async fn post_decrypt(Json(req): Json<DecryptRequest>) -> Json<Value> {
848    match crate::answers_crypto::decrypt_tree(&req.doc, &req.key) {
849        Ok(decrypted) => Json(serde_json::json!({ "ok": true, "doc": decrypted })),
850        Err(e) => Json(serde_json::json!({ "ok": false, "error": e.to_string() })),
851    }
852}
853
854// ── OAuth connect (embedded authorization-code flow) ──
855
856/// Default authorize endpoint when the request omits one (HubSpot).
857const DEFAULT_AUTHORIZE_URL: &str = "https://app.hubspot.com/oauth/authorize";
858/// Default token endpoint when the request omits one (HubSpot).
859const DEFAULT_TOKEN_URL: &str = "https://api.hubapi.com/oauth/v1/token";
860/// Default scopes requested when the request omits them (HubSpot CRM).
861const DEFAULT_SCOPES: &str = "oauth crm.objects.contacts.read crm.objects.contacts.write crm.objects.companies.read crm.objects.companies.write crm.objects.deals.read crm.objects.deals.write tickets";
862
863/// Monotonic counter used to make embedded OAuth `state` tokens unique within a
864/// single setup session (predictable is acceptable on localhost, single user).
865static OAUTH_STATE_COUNTER: AtomicU64 = AtomicU64::new(0);
866
867#[derive(Deserialize)]
868struct OauthStartRequest {
869    provider: String,
870    pack_provider: String,
871    client_id: String,
872    client_secret: String,
873    #[serde(default)]
874    authorize_url: Option<String>,
875    #[serde(default)]
876    token_url: Option<String>,
877    #[serde(default)]
878    scopes: Option<String>,
879    #[serde(default)]
880    env: Option<String>,
881    #[serde(default)]
882    tenant: Option<String>,
883    #[serde(default)]
884    team: Option<String>,
885}
886
887/// Begin the embedded OAuth authorization-code flow.
888///
889/// Stores the (server-side only) `client_secret` plus exchange parameters keyed
890/// by a generated `state` token, then returns the provider authorize URL the
891/// browser should open. The redirect points back at this same server's
892/// `/api/oauth/callback` endpoint — no external broker process is involved.
893async fn post_oauth_start(
894    State(state): State<std::sync::Arc<UiState>>,
895    Json(req): Json<OauthStartRequest>,
896) -> Json<Value> {
897    let env = req.env.unwrap_or_else(|| state.env.clone());
898    let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
899    let team = req.team.or_else(|| state.team.clone());
900    let provider = req.provider;
901
902    let authorize_url = req
903        .authorize_url
904        .filter(|value| !value.is_empty())
905        .unwrap_or_else(|| DEFAULT_AUTHORIZE_URL.to_string());
906    let token_url = req
907        .token_url
908        .filter(|value| !value.is_empty())
909        .unwrap_or_else(|| DEFAULT_TOKEN_URL.to_string());
910    let scopes = req
911        .scopes
912        .filter(|value| !value.is_empty())
913        .unwrap_or_else(|| DEFAULT_SCOPES.to_string());
914
915    let redirect_uri = format!("http://localhost:{}/api/oauth/callback", state.self_port);
916
917    // Unique-but-predictable state token (localhost single-user setup flow).
918    let counter = OAUTH_STATE_COUNTER.fetch_add(1, Ordering::Relaxed);
919    let pending_len = state.oauth_pending.lock().unwrap().len();
920    let state_token = format!("{provider}-{pending_len}-{counter}");
921
922    let (pkce_verifier, pkce_challenge) = generate_pkce();
923
924    let built_authorize_url = format!(
925        "{authorize_url}?client_id={client_id}&redirect_uri={redirect}&scope={scope}&response_type=code&state={state_token}&code_challenge={challenge}&code_challenge_method=S256",
926        client_id = encode(&req.client_id),
927        redirect = encode(&redirect_uri),
928        scope = encode(&scopes),
929        state_token = encode(&state_token),
930        challenge = encode(&pkce_challenge),
931    );
932
933    state.oauth_pending.lock().unwrap().insert(
934        state_token,
935        OauthPending {
936            provider,
937            pack_provider: req.pack_provider,
938            env,
939            tenant,
940            team,
941            token_url,
942            client_id: req.client_id,
943            client_secret: req.client_secret,
944            redirect_uri,
945            pkce_verifier,
946        },
947    );
948
949    Json(serde_json::json!({ "authorize_url": built_authorize_url }))
950}
951
952#[derive(Deserialize)]
953struct OauthCallbackQuery {
954    #[serde(default)]
955    code: Option<String>,
956    #[serde(default)]
957    state: Option<String>,
958    #[serde(default)]
959    error: Option<String>,
960}
961
962/// Embedded OAuth redirect target.
963///
964/// Exchanges the authorization `code` for tokens at the provider token endpoint,
965/// persists the resulting tokens (plus `auth_mode=oauth`) as dev-store secrets,
966/// marks the provider connected, and returns an HTML page for the popup window.
967/// Never logs or echoes the client secret or any token.
968async fn get_oauth_callback(
969    State(state): State<std::sync::Arc<UiState>>,
970    axum::extract::Query(query): axum::extract::Query<OauthCallbackQuery>,
971) -> impl IntoResponse {
972    if let Some(error) = query.error.as_deref().filter(|value| !value.is_empty()) {
973        return oauth_html(400, &format!("Authorization failed: {}", esc_html(error)));
974    }
975    let Some(code) = query.code.filter(|value| !value.is_empty()) else {
976        return oauth_html(400, "Missing authorization code.");
977    };
978    let Some(state_token) = query.state.filter(|value| !value.is_empty()) else {
979        return oauth_html(400, "Missing state parameter.");
980    };
981
982    let pending = state.oauth_pending.lock().unwrap().remove(&state_token);
983    let Some(pending) = pending else {
984        return oauth_html(
985            400,
986            "Unknown or expired state. Please retry the connection.",
987        );
988    };
989
990    // Exchange the code for tokens using blocking ureq off the async runtime.
991    let token_url = pending.token_url.clone();
992    let body = format!(
993        "grant_type=authorization_code&client_id={client_id}&client_secret={client_secret}&redirect_uri={redirect}&code={code}&code_verifier={verifier}",
994        client_id = encode(&pending.client_id),
995        client_secret = encode(&pending.client_secret),
996        redirect = encode(&pending.redirect_uri),
997        code = encode(&code),
998        verifier = encode(&pending.pkce_verifier),
999    );
1000    let exchange = tokio::task::spawn_blocking(move || -> Result<(u16, String), String> {
1001        // Keep non-2xx responses (instead of an error) so we can show the body.
1002        let response = ureq::post(&token_url)
1003            .config()
1004            .http_status_as_error(false)
1005            .build()
1006            .header("Content-Type", "application/x-www-form-urlencoded")
1007            .send(body.as_bytes())
1008            .map_err(|err| err.to_string())?;
1009        let status = response.status().as_u16();
1010        let text = response
1011            .into_body()
1012            .read_to_string()
1013            .map_err(|err| err.to_string())?;
1014        Ok((status, text))
1015    })
1016    .await;
1017
1018    let token_body = match exchange {
1019        Ok(Ok((status, body))) if (200..300).contains(&status) => body,
1020        Ok(Ok((status, body))) => {
1021            return oauth_html(
1022                502,
1023                &format!(
1024                    "Token exchange failed (HTTP {status}): {}",
1025                    esc_html(&truncate(&body, 300))
1026                ),
1027            );
1028        }
1029        Ok(Err(msg)) => {
1030            return oauth_html(
1031                502,
1032                &format!("Token exchange failed: {}", esc_html(&truncate(&msg, 300))),
1033            );
1034        }
1035        Err(join_err) => {
1036            return oauth_html(
1037                500,
1038                &format!(
1039                    "Token exchange task failed: {}",
1040                    esc_html(&join_err.to_string())
1041                ),
1042            );
1043        }
1044    };
1045
1046    let parsed: Value = match serde_json::from_str(&token_body) {
1047        Ok(value) => value,
1048        Err(err) => {
1049            return oauth_html(
1050                502,
1051                &format!("Invalid token response: {}", esc_html(&err.to_string())),
1052            );
1053        }
1054    };
1055
1056    let access_token = parsed
1057        .get("access_token")
1058        .and_then(Value::as_str)
1059        .unwrap_or_default()
1060        .to_string();
1061    let refresh_token = parsed
1062        .get("refresh_token")
1063        .and_then(Value::as_str)
1064        .unwrap_or_default()
1065        .to_string();
1066    if access_token.is_empty() && refresh_token.is_empty() {
1067        return oauth_html(
1068            502,
1069            &format!(
1070                "Token response missing tokens: {}",
1071                esc_html(&truncate(&token_body, 300))
1072            ),
1073        );
1074    }
1075
1076    if let Err(err) = persist_oauth_secrets(&state, &pending, &access_token, &refresh_token).await {
1077        return oauth_html(
1078            500,
1079            &format!(
1080                "Failed to store credentials: {}",
1081                esc_html(&err.to_string())
1082            ),
1083        );
1084    }
1085
1086    let key = format!("{}:{}:{}", pending.provider, pending.env, pending.tenant);
1087    state.oauth_connected.lock().unwrap().insert(key);
1088
1089    oauth_html(
1090        200,
1091        "✓ HubSpot connected — token stored. You can close this window.",
1092    )
1093}
1094
1095/// Persist OAuth credentials to the dev secrets store under the pack provider.
1096async fn persist_oauth_secrets(
1097    state: &UiState,
1098    pending: &OauthPending,
1099    access_token: &str,
1100    refresh_token: &str,
1101) -> Result<()> {
1102    let store = crate::secrets::open_dev_store(&state.bundle_path)?;
1103
1104    let pairs: [(&str, &str); 5] = [
1105        ("auth_mode", "oauth"),
1106        ("oauth_refresh_token", refresh_token),
1107        ("oauth_client_id", &pending.client_id),
1108        ("oauth_client_secret", &pending.client_secret),
1109        ("access_token", access_token),
1110    ];
1111
1112    let entries: Vec<SeedEntry> = pairs
1113        .iter()
1114        .map(|(key, value)| SeedEntry {
1115            uri: crate::canonical_secret_uri(
1116                &pending.env,
1117                &pending.tenant,
1118                pending.team.as_deref(),
1119                &pending.pack_provider,
1120                key,
1121            ),
1122            format: SecretFormat::Text,
1123            value: SeedValue::Text {
1124                text: (*value).to_string(),
1125            },
1126            description: Some(format!("embedded OAuth for {}", pending.pack_provider)),
1127        })
1128        .collect();
1129
1130    let report = apply_seed(&store, &SeedDoc { entries }, ApplyOptions::default()).await;
1131    if !report.failed.is_empty() {
1132        return Err(anyhow::anyhow!(
1133            "failed to persist {} OAuth secret(s)",
1134            report.failed.len()
1135        ));
1136    }
1137    Ok(())
1138}
1139
1140#[derive(Deserialize)]
1141struct OauthStatusQuery {
1142    provider: String,
1143    #[serde(default)]
1144    env: Option<String>,
1145    #[serde(default)]
1146    tenant: Option<String>,
1147    #[serde(default)]
1148    team: Option<String>,
1149}
1150
1151/// Report whether the embedded OAuth flow has completed for a provider.
1152///
1153/// Returns `{ connected }` based on the in-memory connected set; the SPA polls
1154/// this after opening the authorize popup.
1155async fn get_oauth_status(
1156    State(state): State<std::sync::Arc<UiState>>,
1157    axum::extract::Query(query): axum::extract::Query<OauthStatusQuery>,
1158) -> Json<Value> {
1159    let env = query.env.unwrap_or_else(|| state.env.clone());
1160    let tenant = query.tenant.unwrap_or_else(|| state.tenant.clone());
1161    // team is resolved for parity with `start` but is not part of the status key.
1162    let _team = query.team.or_else(|| state.team.clone());
1163    let provider = query.provider;
1164
1165    let key = format!("{provider}:{env}:{tenant}");
1166    let connected = state.oauth_connected.lock().unwrap().contains(&key);
1167    Json(serde_json::json!({ "connected": connected }))
1168}
1169
1170/// Percent-encode a query/body component using the `url` crate's form encoder.
1171fn encode(value: &str) -> String {
1172    url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
1173}
1174
1175/// Minimal HTML-escape for text interpolated into the callback page.
1176fn esc_html(value: &str) -> String {
1177    value
1178        .replace('&', "&amp;")
1179        .replace('<', "&lt;")
1180        .replace('>', "&gt;")
1181}
1182
1183/// Truncate a string to `max` bytes (on a char boundary) for error display.
1184fn truncate(value: &str, max: usize) -> String {
1185    if value.len() <= max {
1186        return value.to_string();
1187    }
1188    let mut end = max;
1189    while end > 0 && !value.is_char_boundary(end) {
1190        end -= 1;
1191    }
1192    format!("{}…", &value[..end])
1193}
1194
1195/// Build a dark-themed HTML response page for the OAuth popup window.
1196fn oauth_html(status: u16, message: &str) -> axum::response::Response {
1197    let status_code =
1198        axum::http::StatusCode::from_u16(status).unwrap_or(axum::http::StatusCode::OK);
1199    let body = format!(
1200        "<!doctype html><html><head><meta charset=\"utf-8\"><title>Greentic OAuth</title>\
1201<style>html,body{{height:100%;margin:0}}body{{display:flex;align-items:center;justify-content:center;\
1202background:#0d1117;color:#e6edf3;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}}\
1203.card{{max-width:440px;padding:2rem;text-align:center;line-height:1.5}}</style></head>\
1204<body><div class=\"card\"><p>{}</p></div></body></html>",
1205        message
1206    );
1207    (
1208        status_code,
1209        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1210        body,
1211    )
1212        .into_response()
1213}
1214
1215async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
1216    let _ = state.shutdown_tx.send(());
1217}
1218
1219// ── Execution ──
1220
1221fn execute_setup(
1222    bundle_path: &Path,
1223    tenant: &str,
1224    team: Option<&str>,
1225    env: &str,
1226    answers: JsonMap<String, Value>,
1227) -> ExecutionResult {
1228    let config = SetupConfig {
1229        tenant: tenant.to_string(),
1230        team: team.map(String::from),
1231        env: env.to_string(),
1232        offline: false,
1233        verbose: true,
1234    };
1235
1236    let static_routes = match StaticRoutesPolicy::normalize(None, env) {
1237        Ok(sr) => sr,
1238        Err(e) => {
1239            return ExecutionResult {
1240                success: false,
1241                stdout: String::new(),
1242                stderr: format!("Failed to normalize static routes: {e}"),
1243                manual_steps: vec![],
1244            };
1245        }
1246    };
1247
1248    // Collect manual steps before moving answers into request
1249    let provider_configs: Vec<(String, serde_json::Value)> = answers
1250        .iter()
1251        .map(|(id, val)| (id.clone(), val.clone()))
1252        .collect();
1253    let team_str = team.unwrap_or("default");
1254    let manual_steps =
1255        crate::webhook::collect_post_setup_instructions(&provider_configs, tenant, team_str);
1256
1257    let request = SetupRequest {
1258        bundle: bundle_path.to_path_buf(),
1259        tenants: vec![TenantSelection {
1260            tenant: tenant.to_string(),
1261            team: team.map(String::from),
1262            allow_paths: Vec::new(),
1263        }],
1264        static_routes,
1265        deployment_targets: Vec::new(),
1266        setup_answers: answers,
1267        ..Default::default()
1268    };
1269
1270    let engine = SetupEngine::new(config);
1271
1272    let plan = match engine.plan(SetupMode::Create, &request, false) {
1273        Ok(p) => p,
1274        Err(e) => {
1275            return ExecutionResult {
1276                success: false,
1277                stdout: String::new(),
1278                stderr: format!("Failed to build plan: {e}"),
1279                manual_steps: vec![],
1280            };
1281        }
1282    };
1283
1284    // Capture plan summary
1285    let mut stdout = String::new();
1286    for step in &plan.steps {
1287        stdout.push_str(&format!("  {:?}: {}\n", step.kind, step.description));
1288    }
1289
1290    match engine.execute(&plan) {
1291        Ok(report) => {
1292            stdout.push_str(&format!(
1293                "\n{} provider(s) updated, {} pack(s) resolved.\n",
1294                report.provider_updates,
1295                report.resolved_packs.len()
1296            ));
1297            if !report.warnings.is_empty() {
1298                for w in &report.warnings {
1299                    stdout.push_str(&format!("  warning: {w}\n"));
1300                }
1301            }
1302            ExecutionResult {
1303                success: true,
1304                stdout: format!(
1305                    "Plan ({} steps):\n{stdout}Setup completed successfully.",
1306                    plan.steps.len()
1307                ),
1308                stderr: String::new(),
1309                manual_steps,
1310            }
1311        }
1312        Err(e) => ExecutionResult {
1313            success: false,
1314            stdout,
1315            stderr: format!("Execution failed: {e}"),
1316            manual_steps: vec![],
1317        },
1318    }
1319}
1320
1321// ── Helpers ──
1322
1323/// Load previously saved secret values from the dev store for all providers.
1324async fn load_saved_secrets(
1325    bundle_path: &Path,
1326    env: &str,
1327    tenant: &str,
1328    team: Option<&str>,
1329    provider_form_specs: &[wizard::ProviderFormSpec],
1330) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
1331    use greentic_secrets_lib::SecretsStore;
1332
1333    let store = match crate::secrets::open_dev_store(bundle_path) {
1334        Ok(s) => s,
1335        Err(_) => return std::collections::HashMap::new(),
1336    };
1337
1338    let mut result = std::collections::HashMap::new();
1339    for pfs in provider_form_specs {
1340        let mut values = std::collections::HashMap::new();
1341        for q in &pfs.form_spec.questions {
1342            let uri = crate::canonical_secret_uri(env, tenant, team, &pfs.provider_id, &q.id);
1343            if let Ok(bytes) = store.get(&uri).await
1344                && let Ok(text) = String::from_utf8(bytes)
1345                && !text.is_empty()
1346            {
1347                values.insert(q.id.clone(), text);
1348            }
1349        }
1350        if !values.is_empty() {
1351            result.insert(pfs.provider_id.clone(), values);
1352        }
1353    }
1354    result
1355}
1356
1357async fn persist_ui_draft(
1358    bundle_path: &Path,
1359    tenant: &str,
1360    team: Option<&str>,
1361    env: &str,
1362    answers: &JsonMap<String, Value>,
1363) -> Result<JsonMap<String, Value>> {
1364    let discovered = discovery::discover(bundle_path).ok();
1365    let mut persisted = JsonMap::new();
1366
1367    for (provider_id, provider_answers) in answers {
1368        let Some(config) = provider_answers.as_object() else {
1369            continue;
1370        };
1371        if config.is_empty() {
1372            continue;
1373        }
1374
1375        let pack_path = discovered.as_ref().and_then(|d| {
1376            d.find_setup_target(provider_id)
1377                .map(|provider| provider.pack_path.as_path())
1378        });
1379
1380        let keys = crate::qa::persist::persist_all_config_as_secrets(
1381            bundle_path,
1382            env,
1383            tenant,
1384            team,
1385            provider_id,
1386            provider_answers,
1387            pack_path,
1388        )
1389        .await?;
1390
1391        if !keys.is_empty() {
1392            persisted.insert(provider_id.clone(), serde_json::to_value(keys)?);
1393        }
1394    }
1395
1396    Ok(persisted)
1397}
1398
1399/// Extract a non-empty string from a JSON value (handles String, Number, Bool).
1400fn value_as_nonempty_string(v: &Value) -> Option<String> {
1401    match v {
1402        Value::String(s) if !s.is_empty() => Some(s.clone()),
1403        Value::Number(n) => Some(n.to_string()),
1404        Value::Bool(b) => Some(b.to_string()),
1405        _ => None,
1406    }
1407}
1408
1409fn form_question_to_info(q: &qa_spec::QuestionSpec, i18n: Option<&CliI18n>) -> QuestionInfo {
1410    let visible_if = q.visible_if.as_ref().and_then(|v| match v {
1411        qa_spec::Expr::Eq { left, right } => {
1412            let field = match left.as_ref() {
1413                qa_spec::Expr::Answer { path } => path.clone(),
1414                _ => return None,
1415            };
1416            let eq = match right.as_ref() {
1417                qa_spec::Expr::Literal { value } => {
1418                    Some(value.as_str().unwrap_or("true").to_string())
1419                }
1420                _ => None,
1421            };
1422            Some(VisibleIfInfo { field, eq })
1423        }
1424        qa_spec::Expr::Answer { path } => Some(VisibleIfInfo {
1425            field: path.clone(),
1426            eq: None,
1427        }),
1428        _ => None,
1429    });
1430
1431    // Resolve title and help from i18n if available
1432    let title_key = format!("ui.q.{}", q.id);
1433    let help_key = format!("ui.q.{}.help", q.id);
1434
1435    let title = i18n
1436        .and_then(|i| {
1437            let t = i.t(&title_key);
1438            if t != title_key { Some(t) } else { None }
1439        })
1440        .unwrap_or_else(|| q.title.clone());
1441
1442    let help = i18n
1443        .and_then(|i| {
1444            let t = i.t(&help_key);
1445            if t != help_key { Some(t) } else { None }
1446        })
1447        .or_else(|| q.description.clone());
1448
1449    QuestionInfo {
1450        id: q.id.clone(),
1451        title,
1452        kind: format!("{:?}", q.kind),
1453        required: q.required,
1454        secret: q.secret,
1455        default_value: q.default_value.clone(),
1456        saved_value: None,
1457        help,
1458        choices: q.choices.clone(),
1459        visible_if,
1460        placeholder: None,
1461        group: None,
1462        docs_url: None,
1463        widget: None,
1464    }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469    use super::persist_ui_draft;
1470    use crate::secrets::open_dev_store;
1471    use greentic_secrets_lib::SecretsStore;
1472    use serde_json::{Map as JsonMap, Value, json};
1473    use std::io::Write;
1474    use zip::write::SimpleFileOptions;
1475
1476    fn write_pack_with_secret_requirements(
1477        path: &std::path::Path,
1478        pack_id: &str,
1479        req_json: &str,
1480    ) -> anyhow::Result<()> {
1481        let file = std::fs::File::create(path)?;
1482        let mut zip = zip::ZipWriter::new(file);
1483        zip.start_file("manifest.json", SimpleFileOptions::default())?;
1484        zip.write_all(format!(r#"{{"pack_id":"{pack_id}"}}"#).as_bytes())?;
1485        zip.start_file(
1486            "assets/secret-requirements.json",
1487            SimpleFileOptions::default(),
1488        )?;
1489        zip.write_all(req_json.as_bytes())?;
1490        zip.finish()?;
1491        Ok(())
1492    }
1493
1494    #[tokio::test]
1495    async fn persist_ui_draft_writes_provider_answers_to_dev_store() {
1496        let temp = tempfile::tempdir().expect("tempdir");
1497        let bundle_root = temp.path();
1498        std::fs::create_dir_all(bundle_root.join("packs")).expect("packs dir");
1499
1500        let pack_path = bundle_root.join("packs").join("weatherapi-pack.gtpack");
1501        write_pack_with_secret_requirements(
1502            &pack_path,
1503            "weatherapi-pack",
1504            r#"[{"key":"auth.param.get_weather.key"}]"#,
1505        )
1506        .expect("pack");
1507
1508        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1509            "weatherapi-pack": {
1510                "auth_param_get_weather_key": "test-weather-key"
1511            }
1512        }))
1513        .expect("answers");
1514
1515        let persisted = persist_ui_draft(bundle_root, "dev-tenant", None, "dev", &answers)
1516            .await
1517            .expect("persist draft");
1518        assert_eq!(
1519            persisted.get("weatherapi-pack"),
1520            Some(&json!(["auth_param_get_weather_key"]))
1521        );
1522
1523        let store = open_dev_store(bundle_root).expect("open store");
1524        let base_uri = crate::canonical_secret_uri(
1525            "dev",
1526            "dev-tenant",
1527            None,
1528            "weatherapi-pack",
1529            "auth_param_get_weather_key",
1530        );
1531        let alias_uri = crate::canonical_secret_uri(
1532            "dev",
1533            "dev-tenant",
1534            None,
1535            "weatherapi-pack",
1536            "auth.param.get_weather.key",
1537        );
1538        let base_value =
1539            String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
1540        let alias_value =
1541            String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
1542        assert_eq!(base_value, "test-weather-key");
1543        assert_eq!(alias_value, "test-weather-key");
1544    }
1545}