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::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12use anyhow::{Context, Result, anyhow};
13use axum::extract::{Query, State};
14use axum::http::header;
15use axum::response::IntoResponse;
16use axum::routing::{get, post};
17use axum::{Json, Router};
18use serde::{Deserialize, Serialize};
19use serde_json::{Map as JsonMap, Value};
20use tokio::sync::broadcast;
21
22use crate::cli_i18n::CliI18n;
23use crate::engine::{SetupConfig, SetupRequest};
24use crate::plan::TenantSelection;
25use crate::platform_setup::StaticRoutesPolicy;
26use crate::qa::wizard;
27use crate::setup_tunnel::{
28    SetupTunnel, inject_setup_public_base_url, should_start_setup_tunnel, start_setup_tunnel,
29};
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    /// Where the on-disk artifact should be written back after a successful
47    /// setup. `Some(Archive)` means re-pack the extracted bundle dir into
48    /// a `.gtbundle`; `Some(Directory)` means copy the dir; `None` means
49    /// the user passed a directory and the working dir IS the artifact, so
50    /// no copy/repack is needed.
51    output_target: Option<crate::cli_helpers::SetupOutputTarget>,
52    local_base_url: String,
53    setup_tunnel: Mutex<Option<SetupTunnel>>,
54    shutdown_tx: broadcast::Sender<()>,
55    #[allow(dead_code)]
56    result: Mutex<Option<ExecutionResult>>,
57}
58
59#[derive(Serialize)]
60#[allow(dead_code)]
61struct ProvidersResponse {
62    bundle_path: String,
63    providers: Vec<ProviderInfo>,
64    provider_forms: Vec<ProviderForm>,
65    shared_questions: Vec<QuestionInfo>,
66}
67
68#[derive(Serialize)]
69struct ProviderInfo {
70    provider_id: String,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    display_name: Option<String>,
73    domain: String,
74    question_count: usize,
75}
76
77#[derive(Serialize)]
78struct ProviderForm {
79    provider_id: String,
80    title: String,
81    questions: Vec<QuestionInfo>,
82}
83
84#[derive(Serialize, Clone)]
85struct QuestionInfo {
86    id: String,
87    title: String,
88    kind: String,
89    required: bool,
90    secret: bool,
91    default_value: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    saved_value: Option<String>,
94    /// Pre-populated rows for `kind: List` questions, hydrated on wizard
95    /// re-run from the bundle's existing tenant config (e.g. nav_links).
96    /// Each entry is a JSON object keyed by `column.id` whose value matches
97    /// the column kind (string for scalars, locale-keyed object for
98    /// multilingual cells).
99    #[serde(skip_serializing_if = "Option::is_none")]
100    saved_rows: Option<Vec<Value>>,
101    help: Option<String>,
102    choices: Option<Vec<String>>,
103    visible_if: Option<VisibleIfInfo>,
104    placeholder: Option<String>,
105    group: Option<String>,
106    docs_url: Option<String>,
107    /// Column schema for `kind: List` (table) questions. Each entry tells
108    /// the front-end how to render one cell per row. Absent for scalar
109    /// kinds.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    list_columns: Option<Vec<ListColumnInfo>>,
112    /// Minimum row count for a `kind: List` question.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    min_rows: Option<usize>,
115    /// Maximum row count for a `kind: List` question.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    max_rows: Option<usize>,
118}
119
120/// Per-column metadata sent to the front-end so it can render one input
121/// per cell when the question kind is `List` (a.k.a. table).
122#[derive(Serialize, Clone)]
123struct ListColumnInfo {
124    id: String,
125    title: String,
126    kind: String,
127    required: bool,
128    help: Option<String>,
129    placeholder: Option<String>,
130    choices: Option<Vec<String>>,
131    default_value: Option<String>,
132    /// When true, the front-end renders a multi-locale cell — operator can
133    /// add per-locale translations via "+ Add language". Persisted as a
134    /// locale-keyed object instead of a plain string.
135    #[serde(skip_serializing_if = "std::ops::Not::not")]
136    multilingual: bool,
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    /// Per-column metadata for `kind: table` questions. Maps column `key`
151    /// → multilingual flag. Used by the UI to render i18n-aware cells.
152    /// Empty for non-table questions.
153    column_multilingual: std::collections::HashMap<String, bool>,
154}
155
156#[derive(Deserialize)]
157struct ExecuteRequest {
158    answers: JsonMap<String, Value>,
159    #[serde(default)]
160    tenant: Option<String>,
161    #[serde(default)]
162    team: Option<String>,
163    #[serde(default)]
164    env: Option<String>,
165    #[serde(default)]
166    tunnel: Option<String>,
167}
168
169#[derive(Deserialize)]
170struct DraftSaveRequest {
171    answers: JsonMap<String, Value>,
172    tenant: String,
173    #[serde(default)]
174    team: Option<String>,
175    env: String,
176}
177
178#[derive(Serialize)]
179struct ScopeResponse {
180    tenant: String,
181    team: Option<String>,
182    env: String,
183    detected_tenant: Option<String>,
184    cloud_deploy: bool,
185}
186
187#[derive(Serialize, Clone)]
188struct ExecutionResult {
189    success: bool,
190    stdout: String,
191    stderr: String,
192    manual_steps: Vec<crate::webhook::ProviderInstruction>,
193    #[serde(default)]
194    pending_setup_actions: Vec<crate::setup_actions::SetupAction>,
195}
196
197// ── Public API ──
198
199/// Launch the setup UI server and open in browser.
200///
201/// When `prefill_answers` is provided (from `--answers` file), the values are
202/// injected into the UI as pre-filled form values so the user can review and
203/// edit before executing.
204#[allow(clippy::too_many_arguments)]
205pub async fn launch(
206    bundle_path: &Path,
207    tenant: &str,
208    team: Option<&str>,
209    env: &str,
210    advanced: bool,
211    locale: Option<&str>,
212    prefill_answers: Option<JsonMap<String, Value>>,
213    _scope_from_answers: bool,
214    output_target: Option<crate::cli_helpers::SetupOutputTarget>,
215) -> Result<()> {
216    let (shutdown_tx, _) = broadcast::channel::<()>(1);
217
218    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
219    let port = listener.local_addr()?.port();
220    let url = format!("http://127.0.0.1:{port}");
221
222    let state = std::sync::Arc::new(UiState {
223        bundle_path: bundle_path.to_path_buf(),
224        tenant: tenant.to_string(),
225        team: team.map(String::from),
226        env: env.to_string(),
227        advanced,
228        locale: locale.map(String::from),
229        prefill_answers,
230        output_target,
231        local_base_url: url.clone(),
232        setup_tunnel: Mutex::new(None),
233        shutdown_tx: shutdown_tx.clone(),
234        result: Mutex::new(None),
235    });
236
237    let router = build_router(state.clone());
238
239    eprintln!("Setup UI started at: {url}");
240    let _ = open::that(&url);
241
242    let mut shutdown_rx = shutdown_tx.subscribe();
243    axum::serve(listener, router)
244        .with_graceful_shutdown(async move {
245            let _ = shutdown_rx.recv().await;
246        })
247        .await?;
248
249    Ok(())
250}
251
252fn build_router(state: std::sync::Arc<UiState>) -> Router {
253    Router::new()
254        .route("/", get(serve_index))
255        .route("/app.js", get(serve_js))
256        .route("/style.css", get(serve_css))
257        .route("/api/locales", get(get_locales))
258        .route("/api/scope", get(get_scope))
259        .route("/api/existing-scopes", get(get_existing_scopes))
260        .route("/api/providers", get(get_providers))
261        .route("/api/draft", post(post_draft))
262        .route("/api/execute", post(post_execute))
263        .route("/api/oauth-device/start", post(post_oauth_device_start))
264        .route("/api/oauth-device/poll", post(post_oauth_device_poll))
265        .route("/api/export", post(post_export))
266        .route("/api/decrypt", post(post_decrypt))
267        .route("/oauth/callback/{provider}", get(get_oauth_callback))
268        .route("/api/shutdown", post(post_shutdown))
269        .with_state(state)
270}
271
272// ── Static assets ──
273
274async fn serve_index() -> impl IntoResponse {
275    (
276        [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
277        assets::INDEX_HTML,
278    )
279}
280
281async fn serve_js() -> impl IntoResponse {
282    (
283        [(
284            header::CONTENT_TYPE,
285            "application/javascript; charset=utf-8",
286        )],
287        assets::APP_JS,
288    )
289}
290
291async fn serve_css() -> impl IntoResponse {
292    (
293        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
294        assets::STYLE_CSS,
295    )
296}
297
298// ── API handlers ──
299
300/// Well-known locales with display labels.
301const LOCALE_OPTIONS: &[(&str, &str)] = &[
302    ("en", "English"),
303    ("id", "Bahasa Indonesia"),
304    ("ja", "日本語"),
305    ("zh", "中文"),
306    ("ko", "한국어"),
307    ("es", "Español"),
308    ("fr", "Français"),
309    ("de", "Deutsch"),
310    ("pt", "Português"),
311    ("ru", "Русский"),
312    ("ar", "العربية"),
313    ("th", "ไทย"),
314    ("vi", "Tiếng Việt"),
315    ("tr", "Türkçe"),
316    ("it", "Italiano"),
317    ("nl", "Nederlands"),
318    ("pl", "Polski"),
319    ("sv", "Svenska"),
320    ("hi", "हिन्दी"),
321    ("ms", "Bahasa Melayu"),
322];
323
324async fn get_locales(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
325    let current = state.locale.as_deref().unwrap_or("en");
326    let locales: Vec<Value> = LOCALE_OPTIONS
327        .iter()
328        .map(|(code, label)| {
329            serde_json::json!({
330                "code": code,
331                "label": label,
332                "selected": *code == current,
333            })
334        })
335        .collect();
336    Json(serde_json::json!({ "locales": locales, "current": current }))
337}
338
339#[derive(Deserialize)]
340struct ProviderQuery {
341    locale: Option<String>,
342}
343
344async fn get_scope(State(state): State<std::sync::Arc<UiState>>) -> Json<ScopeResponse> {
345    let bundle_path = &state.bundle_path;
346    let cli_tenant = &state.tenant;
347    let cli_env = &state.env;
348
349    // Detect tenant from the bundle's tenants/ directory for informational display.
350    let detected_tenant = detect_tenant_from_bundle(bundle_path);
351
352    // The web UI should honor the requested CLI/answers scope. Detected bundle
353    // tenants are informational only; otherwise a scaffold containing both
354    // `demo` and `default` can silently shift setup into the wrong tenant.
355    let effective_tenant = cli_tenant.clone();
356
357    let cloud_deploy = prefill_has_cloud_deployment_targets(state.prefill_answers.as_ref());
358
359    Json(ScopeResponse {
360        tenant: effective_tenant,
361        team: state.team.clone(),
362        env: cli_env.clone(),
363        detected_tenant,
364        cloud_deploy,
365    })
366}
367
368fn prefill_has_cloud_deployment_targets(prefill: Option<&JsonMap<String, Value>>) -> bool {
369    prefill
370        .and_then(|answers| answers.get("platform_setup"))
371        .and_then(|value| value.as_object())
372        .and_then(|platform_setup| platform_setup.get("deployment_targets"))
373        .and_then(|value| value.as_array())
374        .map(|targets| {
375            targets.iter().any(|target| {
376                target
377                    .get("target")
378                    .and_then(Value::as_str)
379                    .is_some_and(|target| matches!(target, "aws" | "gcp" | "azure"))
380            })
381        })
382        .unwrap_or(false)
383}
384
385/// Detect tenant from the bundle's `tenants/` directory.
386fn detect_tenant_from_bundle(bundle_dir: &Path) -> Option<String> {
387    let tenants_dir = bundle_dir.join("tenants");
388    let entries: Vec<String> = std::fs::read_dir(&tenants_dir)
389        .ok()?
390        .filter_map(|e| e.ok())
391        .filter(|e| e.path().is_dir())
392        .filter_map(|e| e.file_name().into_string().ok())
393        .collect();
394
395    match entries.len() {
396        0 => None,
397        1 => Some(entries[0].clone()),
398        _ => entries
399            .iter()
400            .find(|t| t.as_str() != "demo")
401            .cloned()
402            .or_else(|| entries.first().cloned()),
403    }
404}
405
406/// Scan the bundle for previously configured scopes.
407///
408/// Reads `state/config/*/setup-answers.json` for provider answers and
409/// probes the dev secrets store with detected tenants to reconstruct
410/// existing scope configurations.
411async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
412    let bundle_path = &state.bundle_path;
413
414    // 1. Detect tenants from tenants/ directory
415    let tenants = {
416        let mut t = Vec::new();
417        let tenants_dir = bundle_path.join("tenants");
418        if let Ok(entries) = std::fs::read_dir(&tenants_dir) {
419            for entry in entries.flatten() {
420                if entry.path().is_dir()
421                    && let Some(name) = entry.file_name().to_str()
422                {
423                    t.push(name.to_string());
424                }
425            }
426        }
427        if t.is_empty() {
428            t.push(state.tenant.clone());
429        }
430        t.sort();
431        if let Some(pos) = t.iter().position(|tenant| tenant == &state.tenant) {
432            let selected = t.remove(pos);
433            t.insert(0, selected);
434        }
435        t
436    };
437
438    // 2. Read provider answers from state/config/*/setup-answers.json
439    let config_dir = bundle_path.join("state").join("config");
440    let mut provider_answers: JsonMap<String, Value> = JsonMap::new();
441    if let Ok(entries) = std::fs::read_dir(&config_dir) {
442        for entry in entries.flatten() {
443            if !entry.path().is_dir() {
444                continue;
445            }
446            let provider_id = entry.file_name().to_string_lossy().to_string();
447            let answers_file = entry.path().join("setup-answers.json");
448            if let Ok(content) = std::fs::read_to_string(&answers_file)
449                && let Ok(parsed) = serde_json::from_str::<Value>(&content)
450            {
451                provider_answers.insert(provider_id, parsed);
452            }
453        }
454    }
455
456    // 3. For each tenant, probe secrets store to see if secrets exist
457    let discovered = discovery::discover(bundle_path).ok();
458    let provider_form_specs: Vec<wizard::ProviderFormSpec> = discovered
459        .iter()
460        .flat_map(|d| d.setup_targets())
461        .filter_map(|p| {
462            setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id).map(|fs| {
463                wizard::ProviderFormSpec {
464                    provider_id: p.provider_id.clone(),
465                    form_spec: fs,
466                }
467            })
468        })
469        .collect();
470
471    let envs_to_probe = ["dev", "local"];
472    let mut scopes = Vec::new();
473
474    for tenant in &tenants {
475        for env in &envs_to_probe {
476            let saved =
477                load_saved_secrets(bundle_path, env, tenant, None, &provider_form_specs).await;
478
479            if saved.is_empty() {
480                continue;
481            }
482
483            // Merge saved secrets with file-based answers
484            let mut merged_answers = JsonMap::new();
485            for (pid, file_ans) in &provider_answers {
486                let mut cloned = file_ans.clone();
487                // Migrate legacy `<id>_json` string answers to their array
488                // equivalent (the new `kind: table` wizard writes the array
489                // form). Without this the legacy ghost dominates the prefill
490                // and silently overrides the user's table edits on the next
491                // sync.
492                // Currently we only know one legacy `_json` string key —
493                // `nav_links_json`. Open-coded rather than looping a single
494                // element. If we add more table questions later, swap to a
495                // const slice + for loop again.
496                if let Some(map) = cloned.as_object_mut() {
497                    let legacy_key = "nav_links_json";
498                    let canonical_key = "nav_links";
499                    if !map.contains_key(canonical_key)
500                        && let Some(Value::String(raw)) = map.get(legacy_key)
501                        && let Ok(parsed) = serde_json::from_str::<Value>(raw)
502                        && parsed.is_array()
503                    {
504                        map.insert(canonical_key.to_string(), parsed);
505                    }
506                    map.remove(legacy_key);
507                }
508                merged_answers.insert(pid.clone(), cloned);
509            }
510            // Overlay saved secrets into answers
511            for (pid, secrets) in &saved {
512                let entry = merged_answers
513                    .entry(pid.clone())
514                    .or_insert_with(|| Value::Object(JsonMap::new()));
515                if let Some(obj) = entry.as_object_mut() {
516                    for (k, v) in secrets {
517                        obj.insert(k.clone(), Value::String(v.clone()));
518                    }
519                }
520            }
521
522            scopes.push(serde_json::json!({
523                "tenant": tenant,
524                "env": env,
525                "team": null,
526                "answers": merged_answers,
527                "providers_done": saved.keys().collect::<Vec<_>>(),
528            }));
529            break; // found secrets for this tenant, skip other envs
530        }
531    }
532
533    Json(serde_json::json!({ "scopes": scopes }))
534}
535
536async fn get_providers(
537    State(state): State<std::sync::Arc<UiState>>,
538    axum::extract::Query(query): axum::extract::Query<ProviderQuery>,
539) -> Json<Value> {
540    let bundle_path = &state.bundle_path;
541
542    // Use query locale override, fall back to CLI locale
543    let locale = query.locale.as_deref().or(state.locale.as_deref());
544
545    // Load i18n strings for the UI
546    let i18n = CliI18n::from_request(locale)
547        .unwrap_or_else(|_| CliI18n::from_request(Some("en")).expect("en locale must exist"));
548    let ui_strings = i18n.keys_with_prefix("ui.");
549
550    let discovered = match discovery::discover(bundle_path) {
551        Ok(d) => d,
552        Err(e) => {
553            return Json(serde_json::json!({
554                "bundle_path": bundle_path.display().to_string(),
555                "providers": [],
556                "provider_forms": [],
557                "shared_questions": [],
558                "i18n": ui_strings,
559                "error": e.to_string(),
560            }));
561        }
562    };
563
564    let setup_targets = discovered.setup_targets();
565
566    let provider_form_specs: Vec<wizard::ProviderFormSpec> = setup_targets
567        .iter()
568        .filter_map(|provider| {
569            setup_to_formspec::pack_to_form_spec(&provider.pack_path, &provider.provider_id).map(
570                |form_spec| wizard::ProviderFormSpec {
571                    provider_id: provider.provider_id.clone(),
572                    form_spec,
573                },
574            )
575        })
576        .collect();
577
578    // Detect shared questions (saved values injected after secrets are loaded below)
579    let shared_question_specs = if provider_form_specs.len() > 1 {
580        wizard::collect_shared_questions(&provider_form_specs)
581            .shared_questions
582            .clone()
583    } else {
584        vec![]
585    };
586
587    let providers: Vec<ProviderInfo> = setup_targets
588        .iter()
589        .map(|p| {
590            let form = setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id);
591            ProviderInfo {
592                provider_id: p.provider_id.clone(),
593                display_name: p.display_name.clone(),
594                domain: p.domain.clone(),
595                question_count: form.as_ref().map(|f| f.questions.len()).unwrap_or(0),
596            }
597        })
598        .collect();
599
600    // Build lookup maps for extra fields (placeholder, group, docs_url) from setup.yaml
601    let mut extras_by_provider: std::collections::HashMap<
602        String,
603        std::collections::HashMap<String, SetupQuestionExtras>,
604    > = std::collections::HashMap::new();
605    for provider in &setup_targets {
606        if let Ok(Some(spec)) = crate::setup_input::load_setup_spec(&provider.pack_path) {
607            let mut map = std::collections::HashMap::new();
608            for q in &spec.questions {
609                let mut column_multilingual = std::collections::HashMap::new();
610                for col in &q.columns {
611                    if col.multilingual {
612                        column_multilingual.insert(col.key.clone(), true);
613                    }
614                }
615                map.insert(
616                    q.name.clone(),
617                    SetupQuestionExtras {
618                        placeholder: q.placeholder.clone(),
619                        group: q.group.clone(),
620                        docs_url: q.docs_url.clone(),
621                        column_multilingual,
622                    },
623                );
624            }
625            extras_by_provider.insert(provider.provider_id.clone(), map);
626        }
627    }
628
629    // Load saved secrets from dev store for auto-fill
630    let saved_secrets = load_saved_secrets(
631        bundle_path,
632        &state.env,
633        &state.tenant,
634        state.team.as_deref(),
635        &provider_form_specs,
636    )
637    .await;
638
639    // Build per-provider prefill map from --answers file (overrides saved secrets)
640    let prefill = &state.prefill_answers;
641
642    // Inject saved values into shared questions (pick from first provider that has the value)
643    // Answers from --answers file take priority over saved secrets.
644    // Filter out questions that are auto-injected by the operator (e.g. public_base_url).
645    let shared_questions: Vec<QuestionInfo> = shared_question_specs
646        .iter()
647        .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
648        .map(|q| {
649            let mut info = form_question_to_info(q, Some(&i18n));
650            // First try --answers prefill (check all providers for the shared question)
651            let mut found = false;
652            if let Some(answers) = prefill {
653                for pfs in &provider_form_specs {
654                    if let Some(provider_answers) =
655                        answers.get(&pfs.provider_id).and_then(|v| v.as_object())
656                        && let Some(val) = provider_answers
657                            .get(&q.id)
658                            .and_then(value_as_nonempty_string)
659                    {
660                        info.saved_value = Some(val);
661                        found = true;
662                        break;
663                    }
664                }
665            }
666            // Fall back to saved secrets
667            if !found {
668                for secrets in saved_secrets.values() {
669                    if let Some(val) = secrets.get(&q.id) {
670                        info.saved_value = Some(val.clone());
671                        break;
672                    }
673                }
674            }
675            info
676        })
677        .collect();
678
679    let provider_forms: Vec<ProviderForm> = provider_form_specs
680        .iter()
681        .map(|pfs| {
682            let extras = extras_by_provider.get(&pfs.provider_id);
683            let saved = saved_secrets.get(&pfs.provider_id);
684            let answers = prefill
685                .as_ref()
686                .and_then(|a| a.get(&pfs.provider_id))
687                .and_then(|v| v.as_object());
688            ProviderForm {
689                provider_id: pfs.provider_id.clone(),
690                title: pfs.form_spec.title.clone(),
691                questions: pfs
692                    .form_spec
693                    .questions
694                    .iter()
695                    .filter(|q| !HIDDEN_FROM_PROMPTS.contains(&q.id.as_str()))
696                    .map(|q| {
697                        let mut info = form_question_to_info(q, Some(&i18n));
698                        if let Some(ext) = extras.and_then(|m| m.get(&q.id)) {
699                            if info.placeholder.is_none() {
700                                info.placeholder = ext.placeholder.clone();
701                            }
702                            info.group = ext.group.clone();
703                            info.docs_url = ext.docs_url.clone();
704                            // Overlay per-column multilingual flags onto the
705                            // table-rendering metadata (qa-spec QuestionSpec
706                            // has no slot for this hint, so we carry it
707                            // out-of-band via SetupQuestionExtras).
708                            if let Some(ref mut cols) = info.list_columns {
709                                for col in cols.iter_mut() {
710                                    if ext
711                                        .column_multilingual
712                                        .get(&col.id)
713                                        .copied()
714                                        .unwrap_or(false)
715                                    {
716                                        col.multilingual = true;
717                                    }
718                                }
719                            }
720                        }
721                        // --answers prefill takes priority over saved secrets
722                        if let Some(val) = answers
723                            .and_then(|m| m.get(&q.id))
724                            .and_then(value_as_nonempty_string)
725                        {
726                            info.saved_value = Some(val);
727                        } else if let Some(val) = saved.and_then(|m| m.get(&q.id)) {
728                            info.saved_value = Some(val.clone());
729                        }
730                        // Hydrate kind: List rows from --answers (if it
731                        // carries an array) or, for the webchat-gui
732                        // nav_links table, from the bundle's persisted
733                        // tenant.json so a wizard re-run pre-populates the
734                        // pills the operator just configured.
735                        if matches!(q.kind, qa_spec::QuestionType::List) {
736                            if let Some(arr) = answers
737                                .and_then(|m| m.get(&q.id))
738                                .and_then(Value::as_array)
739                                .filter(|a| !a.is_empty())
740                            {
741                                info.saved_rows = Some(arr.clone());
742                                eprintln!(
743                                    "[hydrate] {} {} → saved_rows from prefill: {} row(s)",
744                                    pfs.provider_id,
745                                    q.id,
746                                    arr.len()
747                                );
748                            } else if q.id == "nav_links"
749                                && pfs.provider_id.contains("webchat-gui")
750                            {
751                                match crate::tenant_config::read_existing_nav_links(
752                                    &state.bundle_path,
753                                    &state.tenant,
754                                ) {
755                                    Some(rows) => {
756                                        eprintln!(
757                                            "[hydrate] {} nav_links → saved_rows from tenant.json: {} row(s)",
758                                            pfs.provider_id,
759                                            rows.len()
760                                        );
761                                        info.saved_rows = Some(rows);
762                                    }
763                                    None => {
764                                        eprintln!(
765                                            "[hydrate] {} nav_links → tenant.json had no nav_links (bundle_path={}, tenant={})",
766                                            pfs.provider_id,
767                                            state.bundle_path.display(),
768                                            state.tenant
769                                        );
770                                    }
771                                }
772                            }
773                        }
774                        info
775                    })
776                    .collect(),
777            }
778        })
779        .collect();
780
781    Json(serde_json::json!({
782        "bundle_path": bundle_path.display().to_string(),
783        "providers": providers,
784        "provider_forms": provider_forms,
785        "shared_questions": shared_questions,
786        "i18n": ui_strings,
787    }))
788}
789
790async fn post_execute(
791    State(state): State<std::sync::Arc<UiState>>,
792    Json(req): Json<ExecuteRequest>,
793) -> Json<ExecutionResult> {
794    let bundle_path = state.bundle_path.clone();
795    // Use scope from UI request if provided, otherwise fall back to CLI defaults
796    let tenant = req.tenant.unwrap_or_else(|| state.tenant.clone());
797    let team = req.team.or_else(|| state.team.clone());
798    let env = req.env.unwrap_or_else(|| state.env.clone());
799    let mut answers = req.answers;
800    let tunnel_mode = req.tunnel.as_deref().unwrap_or("off").to_string();
801
802    // Persist tunnel config from the UI selection.
803    if let Some(mode) = req.tunnel.as_deref() {
804        let tunnel = crate::platform_setup::TunnelAnswers {
805            mode: Some(mode.to_string()),
806        };
807        let _ = crate::platform_setup::persist_tunnel_artifact(&state.bundle_path, &tunnel);
808    }
809
810    let setup_public_base_url = if should_start_setup_tunnel(&tunnel_mode, &answers) {
811        match ensure_setup_tunnel(&state, &tunnel_mode).await {
812            Ok(url) => {
813                inject_setup_public_base_url(&mut answers, &url);
814                Some(url)
815            }
816            Err(err) => {
817                return Json(ExecutionResult {
818                    success: false,
819                    stdout: String::new(),
820                    stderr: format!("Failed to start setup tunnel: {err}"),
821                    manual_steps: vec![],
822                    pending_setup_actions: vec![],
823                });
824            }
825        }
826    } else {
827        None
828    };
829
830    let bundle_path_for_repack = bundle_path.clone();
831    let mut result = tokio::task::spawn_blocking(move || {
832        execute_setup(&bundle_path, &tenant, team.as_deref(), &env, answers)
833    })
834    .await
835    .unwrap_or_else(|e| ExecutionResult {
836        success: false,
837        stdout: String::new(),
838        stderr: format!("Task panicked: {e}"),
839        manual_steps: vec![],
840        pending_setup_actions: vec![],
841    });
842    if let Some(public_base_url) = setup_public_base_url.as_deref()
843        && result.success
844    {
845        result.stdout = append_line(
846            &result.stdout,
847            &format!("Setup tunnel public_base_url: {public_base_url}"),
848        );
849    }
850
851    // After a successful UI setup, re-pack the extracted bundle dir back
852    // to its original `.gtbundle` archive (or copy it to a directory
853    // output) so the on-disk artifact reflects the answers the user just
854    // saved. Without this the simple-mode CLI did the write-back but the
855    // UI mode silently dropped it — see bin/greentic_setup.rs:run_ui_mode.
856    if result.success
857        && let Some(target) = state.output_target.clone()
858    {
859        let repack = tokio::task::spawn_blocking(move || -> Result<String, anyhow::Error> {
860            use crate::cli_helpers::{SetupOutputTarget, copy_dir_recursive};
861            use crate::gtbundle;
862            match target {
863                SetupOutputTarget::Archive(out) => {
864                    gtbundle::create_gtbundle(&bundle_path_for_repack, &out).with_context(
865                        || {
866                            format!(
867                                "failed to write configured .gtbundle archive to {}",
868                                out.display()
869                            )
870                        },
871                    )?;
872                    Ok(format!("Configured bundle written to: {}", out.display()))
873                }
874                SetupOutputTarget::Directory(out) => {
875                    if out.exists() {
876                        if out.is_dir() {
877                            std::fs::remove_dir_all(&out).with_context(|| {
878                                format!(
879                                    "failed to replace existing bundle directory {}",
880                                    out.display()
881                                )
882                            })?;
883                        } else {
884                            std::fs::remove_file(&out).with_context(|| {
885                                format!("failed to replace existing bundle file {}", out.display())
886                            })?;
887                        }
888                    }
889                    copy_dir_recursive(&bundle_path_for_repack, &out, false)
890                        .context("failed to write configured local bundle directory")?;
891                    Ok(format!("Configured bundle written to: {}", out.display()))
892                }
893            }
894        })
895        .await;
896        match repack {
897            Ok(Ok(msg)) => result.stdout.push_str(&format!("\n{msg}\n")),
898            Ok(Err(e)) => {
899                result.success = false;
900                result
901                    .stderr
902                    .push_str(&format!("\nWrite-back failed: {e:#}\n"));
903            }
904            Err(e) => {
905                result.success = false;
906                result
907                    .stderr
908                    .push_str(&format!("\nWrite-back panicked: {e}\n"));
909            }
910        }
911    }
912
913    *state.result.lock().unwrap() = Some(result.clone());
914    Json(result)
915}
916
917async fn post_draft(
918    State(state): State<std::sync::Arc<UiState>>,
919    Json(req): Json<DraftSaveRequest>,
920) -> Json<Value> {
921    match persist_ui_draft(
922        &state.bundle_path,
923        &req.tenant,
924        req.team.as_deref(),
925        &req.env,
926        &req.answers,
927    )
928    .await
929    {
930        Ok(persisted) => Json(serde_json::json!({
931            "ok": true,
932            "persisted": persisted,
933        })),
934        Err(err) => Json(serde_json::json!({
935            "ok": false,
936            "error": err.to_string(),
937        })),
938    }
939}
940
941#[derive(Deserialize)]
942struct ExportRequest {
943    scopes: Vec<ExportScope>,
944    #[serde(default)]
945    key: Option<String>,
946}
947
948#[derive(Deserialize)]
949struct ExportScope {
950    tenant: String,
951    #[serde(default)]
952    team: Option<String>,
953    env: String,
954    answers: JsonMap<String, Value>,
955}
956
957async fn post_export(
958    State(state): State<std::sync::Arc<UiState>>,
959    Json(req): Json<ExportRequest>,
960) -> Json<Value> {
961    let bundle_path = state.bundle_path.clone();
962
963    // Discover packs to identify secret fields for encryption
964    let discovered = discovery::discover(&bundle_path).ok();
965    let secret_fields: std::collections::HashSet<String> = discovered
966        .iter()
967        .flat_map(|d| d.setup_targets())
968        .filter_map(|p| setup_to_formspec::pack_to_form_spec(&p.pack_path, &p.provider_id))
969        .flat_map(|spec| spec.questions.into_iter())
970        .filter(|q| q.secret)
971        .map(|q| q.id)
972        .collect();
973
974    let mut scopes_json = Vec::new();
975    for scope in &req.scopes {
976        let mut setup_answers = JsonMap::new();
977        for (provider_id, provider_answers) in &scope.answers {
978            let mut encrypted_answers = JsonMap::new();
979            if let Some(obj) = provider_answers.as_object() {
980                for (field, value) in obj {
981                    if secret_fields.contains(field) && req.key.is_some() {
982                        let key = req.key.as_deref().unwrap();
983                        match crate::answers_crypto::encrypt_value(value, key) {
984                            Ok(enc) => {
985                                encrypted_answers.insert(field.clone(), enc);
986                            }
987                            Err(_) => {
988                                encrypted_answers.insert(field.clone(), value.clone());
989                            }
990                        }
991                    } else {
992                        encrypted_answers.insert(field.clone(), value.clone());
993                    }
994                }
995            }
996            setup_answers.insert(provider_id.clone(), Value::Object(encrypted_answers));
997        }
998        scopes_json.push(serde_json::json!({
999            "tenant": scope.tenant,
1000            "team": scope.team,
1001            "env": scope.env,
1002            "setup_answers": setup_answers,
1003        }));
1004    }
1005
1006    // Single scope → flat format (compatible with --answers)
1007    // Multiple scopes → array format
1008    let doc = if scopes_json.len() == 1 {
1009        let mut single = scopes_json.into_iter().next().unwrap();
1010        if let Some(obj) = single.as_object_mut() {
1011            obj.insert(
1012                "greentic_setup_version".to_string(),
1013                Value::String("1.0.0".to_string()),
1014            );
1015            obj.insert(
1016                "bundle_source".to_string(),
1017                Value::String(bundle_path.display().to_string()),
1018            );
1019        }
1020        single
1021    } else {
1022        serde_json::json!({
1023            "greentic_setup_version": "1.0.0",
1024            "bundle_source": bundle_path.display().to_string(),
1025            "scopes": scopes_json,
1026        })
1027    };
1028
1029    Json(doc)
1030}
1031
1032#[derive(Deserialize)]
1033struct DecryptRequest {
1034    doc: Value,
1035    key: String,
1036}
1037
1038async fn post_decrypt(Json(req): Json<DecryptRequest>) -> Json<Value> {
1039    match crate::answers_crypto::decrypt_tree(&req.doc, &req.key) {
1040        Ok(decrypted) => Json(serde_json::json!({ "ok": true, "doc": decrypted })),
1041        Err(e) => Json(serde_json::json!({ "ok": false, "error": e.to_string() })),
1042    }
1043}
1044
1045async fn get_oauth_callback(
1046    State(state): State<std::sync::Arc<UiState>>,
1047    Query(query): Query<std::collections::HashMap<String, String>>,
1048) -> impl IntoResponse {
1049    let code = query.get("code").cloned().unwrap_or_default();
1050    let oauth_state = query.get("state").cloned().unwrap_or_default();
1051    if code.is_empty() || oauth_state.is_empty() {
1052        return (
1053            axum::http::StatusCode::BAD_REQUEST,
1054            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1055            oauth_callback_page(
1056                false,
1057                "OAuth setup failed",
1058                "OAuth callback missing code or state.",
1059            ),
1060        );
1061    }
1062    match crate::oauth_callback::complete_oauth_callback(
1063        &state.bundle_path,
1064        &state.env,
1065        &crate::oauth_callback::OAuthCallbackInput {
1066            code,
1067            state: oauth_state,
1068        },
1069        "messaging.oauth.v1",
1070    )
1071    .await
1072    {
1073        Ok(report) => {
1074            let message = format!(
1075                "OAuth setup complete for {} ({}/{})",
1076                report.provider_id, report.tenant, report.team
1077            );
1078            (
1079                axum::http::StatusCode::OK,
1080                [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1081                oauth_callback_page(
1082                    true,
1083                    "OAuth setup complete",
1084                    &format!("{message}. You can close this tab and return to setup."),
1085                ),
1086            )
1087        }
1088        Err(err) => (
1089            axum::http::StatusCode::BAD_REQUEST,
1090            [(header::CONTENT_TYPE, "text/html; charset=utf-8")],
1091            oauth_callback_page(false, "OAuth setup failed", &err.to_string()),
1092        ),
1093    }
1094}
1095
1096fn oauth_callback_page(success: bool, title: &str, message: &str) -> String {
1097    let status_class = if success { "success" } else { "error" };
1098    let close_script = if success {
1099        r#"<script>
1100setTimeout(function () {
1101  window.close();
1102}, 800);
1103</script>"#
1104    } else {
1105        ""
1106    };
1107    format!(
1108        r#"<!doctype html>
1109<html lang="en">
1110<head>
1111  <meta charset="utf-8">
1112  <meta name="viewport" content="width=device-width, initial-scale=1">
1113  <title>{title}</title>
1114  <style>
1115    body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f8fb; color: #17202a; }}
1116    main {{ width: min(520px, calc(100vw - 32px)); padding: 28px; border: 1px solid #d7dee8; border-radius: 8px; background: #fff; box-shadow: 0 16px 40px rgba(15, 23, 42, .08); }}
1117    h1 {{ margin: 0 0 12px; font-size: 1.35rem; line-height: 1.25; }}
1118    p {{ margin: 0; line-height: 1.55; color: #465466; }}
1119    .success h1 {{ color: #087f5b; }}
1120    .error h1 {{ color: #b42318; }}
1121  </style>
1122</head>
1123<body>
1124  <main class="{status_class}">
1125    <h1>{title}</h1>
1126    <p>{message}</p>
1127  </main>
1128  {close_script}
1129</body>
1130</html>"#,
1131        title = html_escape(title),
1132        message = html_escape(message),
1133        status_class = status_class,
1134        close_script = close_script
1135    )
1136}
1137
1138fn html_escape(value: &str) -> String {
1139    value
1140        .replace('&', "&amp;")
1141        .replace('<', "&lt;")
1142        .replace('>', "&gt;")
1143        .replace('"', "&quot;")
1144        .replace('\'', "&#39;")
1145}
1146
1147async fn post_oauth_device_start(
1148    State(state): State<std::sync::Arc<UiState>>,
1149    Json(req): Json<crate::oauth_device::OAuthDeviceStartInput>,
1150) -> Json<Value> {
1151    match crate::oauth_device::start_oauth_device_code(
1152        &state.bundle_path,
1153        &req,
1154        crate::oauth_device::DEFAULT_EXTENSION_KEY,
1155    ) {
1156        Ok(report) => Json(serde_json::json!({ "ok": true, "report": report })),
1157        Err(err) => Json(serde_json::json!({ "ok": false, "error": err.to_string() })),
1158    }
1159}
1160
1161async fn post_oauth_device_poll(
1162    State(state): State<std::sync::Arc<UiState>>,
1163    Json(req): Json<crate::oauth_device::OAuthDevicePollInput>,
1164) -> Json<Value> {
1165    match crate::oauth_device::poll_oauth_device_code(
1166        &state.bundle_path,
1167        &state.env,
1168        &req,
1169        crate::oauth_device::DEFAULT_EXTENSION_KEY,
1170    )
1171    .await
1172    {
1173        Ok(report) => Json(serde_json::json!({ "ok": true, "report": report })),
1174        Err(err) => Json(serde_json::json!({ "ok": false, "error": err.to_string() })),
1175    }
1176}
1177
1178async fn post_shutdown(State(state): State<std::sync::Arc<UiState>>) {
1179    let _ = state.shutdown_tx.send(());
1180}
1181
1182// ── Execution ──
1183
1184fn append_line(existing: &str, line: &str) -> String {
1185    if existing.trim().is_empty() {
1186        line.to_string()
1187    } else {
1188        format!("{existing}\n{line}")
1189    }
1190}
1191
1192async fn ensure_setup_tunnel(state: &std::sync::Arc<UiState>, mode: &str) -> Result<String> {
1193    {
1194        let guard = state
1195            .setup_tunnel
1196            .lock()
1197            .map_err(|_| anyhow!("setup tunnel lock poisoned"))?;
1198        if let Some(tunnel) = guard.as_ref()
1199            && tunnel.mode == mode
1200        {
1201            return Ok(tunnel.public_base_url.clone());
1202        }
1203    }
1204
1205    let mode = mode.to_string();
1206    let local_base_url = state.local_base_url.clone();
1207    let tunnel = tokio::task::spawn_blocking(move || start_setup_tunnel(&mode, &local_base_url))
1208        .await
1209        .map_err(|err| anyhow!("setup tunnel task failed: {err}"))??;
1210    let public_base_url = tunnel.public_base_url.clone();
1211    let mut guard = state
1212        .setup_tunnel
1213        .lock()
1214        .map_err(|_| anyhow!("setup tunnel lock poisoned"))?;
1215    *guard = Some(tunnel);
1216    Ok(public_base_url)
1217}
1218
1219fn execute_setup(
1220    bundle_path: &Path,
1221    tenant: &str,
1222    team: Option<&str>,
1223    env: &str,
1224    answers: JsonMap<String, Value>,
1225) -> ExecutionResult {
1226    let config = SetupConfig {
1227        tenant: tenant.to_string(),
1228        team: team.map(String::from),
1229        env: env.to_string(),
1230        offline: false,
1231        verbose: true,
1232    };
1233
1234    let static_routes = match StaticRoutesPolicy::normalize(None, env) {
1235        Ok(sr) => sr,
1236        Err(e) => {
1237            return ExecutionResult {
1238                success: false,
1239                stdout: String::new(),
1240                stderr: format!("Failed to normalize static routes: {e}"),
1241                manual_steps: vec![],
1242                pending_setup_actions: vec![],
1243            };
1244        }
1245    };
1246
1247    // Collect manual steps before moving answers into request
1248    let provider_configs: Vec<(String, serde_json::Value)> = answers
1249        .iter()
1250        .map(|(id, val)| (id.clone(), val.clone()))
1251        .collect();
1252    let team_str = team.unwrap_or("default");
1253    let manual_steps =
1254        crate::webhook::collect_post_setup_instructions(&provider_configs, tenant, team_str);
1255
1256    let request = SetupRequest {
1257        bundle: bundle_path.to_path_buf(),
1258        bundle_name: crate::bundle::read_bundle_name(bundle_path).ok().flatten(),
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                pending_setup_actions: vec![],
1281            };
1282        }
1283    };
1284
1285    // Capture plan summary
1286    let mut stdout = String::new();
1287    for step in &plan.steps {
1288        stdout.push_str(&format!("  {:?}: {}\n", step.kind, step.description));
1289    }
1290
1291    match engine.execute(&plan) {
1292        Ok(report) => {
1293            stdout.push_str(&format!(
1294                "\n{} provider(s) updated, {} pack(s) resolved.\n",
1295                report.provider_updates,
1296                report.resolved_packs.len()
1297            ));
1298            if !report.warnings.is_empty() {
1299                for w in &report.warnings {
1300                    stdout.push_str(&format!("  warning: {w}\n"));
1301                }
1302            }
1303            ExecutionResult {
1304                success: true,
1305                stdout: format!(
1306                    "Plan ({} steps):\n{stdout}Setup completed successfully.",
1307                    plan.steps.len()
1308                ),
1309                stderr: String::new(),
1310                manual_steps,
1311                pending_setup_actions: report.pending_setup_actions,
1312            }
1313        }
1314        Err(e) => ExecutionResult {
1315            success: false,
1316            stdout,
1317            stderr: format!("Execution failed: {e}"),
1318            manual_steps: vec![],
1319            pending_setup_actions: vec![],
1320        },
1321    }
1322}
1323
1324// ── Helpers ──
1325
1326/// Load previously saved secret values from the dev store for all providers.
1327async fn load_saved_secrets(
1328    bundle_path: &Path,
1329    env: &str,
1330    tenant: &str,
1331    team: Option<&str>,
1332    provider_form_specs: &[wizard::ProviderFormSpec],
1333) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
1334    use greentic_secrets_lib::SecretsStore;
1335
1336    let store = match crate::secrets::open_dev_store(bundle_path) {
1337        Ok(s) => s,
1338        Err(_) => return std::collections::HashMap::new(),
1339    };
1340
1341    let mut result = std::collections::HashMap::new();
1342    for pfs in provider_form_specs {
1343        let mut values = std::collections::HashMap::new();
1344        for q in &pfs.form_spec.questions {
1345            let uri = crate::canonical_secret_uri(env, tenant, team, &pfs.provider_id, &q.id);
1346            if let Ok(bytes) = store.get(&uri).await
1347                && let Ok(text) = String::from_utf8(bytes)
1348                && !text.is_empty()
1349            {
1350                values.insert(q.id.clone(), text);
1351            }
1352        }
1353        if !values.is_empty() {
1354            result.insert(pfs.provider_id.clone(), values);
1355        }
1356    }
1357    result
1358}
1359
1360async fn persist_ui_draft(
1361    bundle_path: &Path,
1362    tenant: &str,
1363    team: Option<&str>,
1364    env: &str,
1365    answers: &JsonMap<String, Value>,
1366) -> Result<JsonMap<String, Value>> {
1367    let discovered = discovery::discover(bundle_path).ok();
1368    let mut persisted = JsonMap::new();
1369
1370    for (provider_id, provider_answers) in answers {
1371        let Some(config) = provider_answers.as_object() else {
1372            continue;
1373        };
1374        if config.is_empty() {
1375            continue;
1376        }
1377
1378        let pack_path = discovered.as_ref().and_then(|d| {
1379            d.find_setup_target(provider_id)
1380                .map(|provider| provider.pack_path.as_path())
1381        });
1382
1383        let keys = crate::qa::persist::persist_all_config_as_secrets(
1384            bundle_path,
1385            env,
1386            tenant,
1387            team,
1388            provider_id,
1389            provider_answers,
1390            pack_path,
1391        )
1392        .await?;
1393
1394        if !keys.is_empty() {
1395            persisted.insert(provider_id.clone(), serde_json::to_value(keys)?);
1396        }
1397    }
1398
1399    Ok(persisted)
1400}
1401
1402/// Extract a non-empty string from a JSON value (handles String, Number, Bool).
1403fn value_as_nonempty_string(v: &Value) -> Option<String> {
1404    match v {
1405        Value::String(s) if !s.is_empty() => Some(s.clone()),
1406        Value::Number(n) => Some(n.to_string()),
1407        Value::Bool(b) => Some(b.to_string()),
1408        _ => None,
1409    }
1410}
1411
1412fn form_question_to_info(q: &qa_spec::QuestionSpec, i18n: Option<&CliI18n>) -> QuestionInfo {
1413    let visible_if = q.visible_if.as_ref().and_then(|v| match v {
1414        qa_spec::Expr::Eq { left, right } => {
1415            let field = match left.as_ref() {
1416                qa_spec::Expr::Answer { path } => path.clone(),
1417                _ => return None,
1418            };
1419            let eq = match right.as_ref() {
1420                qa_spec::Expr::Literal { value } => {
1421                    Some(value.as_str().unwrap_or("true").to_string())
1422                }
1423                _ => None,
1424            };
1425            Some(VisibleIfInfo { field, eq })
1426        }
1427        qa_spec::Expr::Answer { path } => Some(VisibleIfInfo {
1428            field: path.clone(),
1429            eq: None,
1430        }),
1431        _ => None,
1432    });
1433
1434    // Resolve title and help from i18n if available
1435    let title_key = format!("ui.q.{}", q.id);
1436    let help_key = format!("ui.q.{}.help", q.id);
1437
1438    let title = i18n
1439        .and_then(|i| {
1440            let t = i.t(&title_key);
1441            if t != title_key { Some(t) } else { None }
1442        })
1443        .unwrap_or_else(|| q.title.clone());
1444
1445    let help = i18n
1446        .and_then(|i| {
1447            let t = i.t(&help_key);
1448            if t != help_key { Some(t) } else { None }
1449        })
1450        .or_else(|| q.description.clone());
1451
1452    let (list_columns, min_rows, max_rows) = q
1453        .list
1454        .as_ref()
1455        .map(|list| {
1456            let cols: Vec<ListColumnInfo> = list
1457                .fields
1458                .iter()
1459                .map(|c| ListColumnInfo {
1460                    id: c.id.clone(),
1461                    title: c.title.clone(),
1462                    kind: format!("{:?}", c.kind),
1463                    required: c.required,
1464                    help: c.description.clone(),
1465                    placeholder: None,
1466                    choices: c.choices.clone(),
1467                    default_value: c.default_value.clone(),
1468                    // multilingual is set by the caller via overlay_setup_extras —
1469                    // qa-spec QuestionSpec has no slot for it, so we leave it
1470                    // false here and let the UI loop fix it up from
1471                    // SetupQuestionExtras.column_multilingual.
1472                    multilingual: false,
1473                })
1474                .collect();
1475            (Some(cols), list.min_items, list.max_items)
1476        })
1477        .unwrap_or((None, None, None));
1478
1479    QuestionInfo {
1480        id: q.id.clone(),
1481        title,
1482        kind: format!("{:?}", q.kind),
1483        required: q.required,
1484        secret: q.secret,
1485        default_value: q.default_value.clone(),
1486        saved_value: None,
1487        saved_rows: None,
1488        help,
1489        choices: q.choices.clone(),
1490        visible_if,
1491        placeholder: None,
1492        group: None,
1493        docs_url: None,
1494        list_columns,
1495        min_rows,
1496        max_rows,
1497    }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502    use super::{persist_ui_draft, prefill_has_cloud_deployment_targets};
1503    use crate::secrets::open_dev_store;
1504    use greentic_secrets_lib::SecretsStore;
1505    use serde_json::{Map as JsonMap, Value, json};
1506    use std::io::Write;
1507    use zip::write::SimpleFileOptions;
1508
1509    fn write_pack_with_secret_requirements(
1510        path: &std::path::Path,
1511        pack_id: &str,
1512        req_json: &str,
1513    ) -> anyhow::Result<()> {
1514        let file = std::fs::File::create(path)?;
1515        let mut zip = zip::ZipWriter::new(file);
1516        zip.start_file("manifest.json", SimpleFileOptions::default())?;
1517        zip.write_all(format!(r#"{{"pack_id":"{pack_id}"}}"#).as_bytes())?;
1518        zip.start_file(
1519            "assets/secret-requirements.json",
1520            SimpleFileOptions::default(),
1521        )?;
1522        zip.write_all(req_json.as_bytes())?;
1523        zip.finish()?;
1524        Ok(())
1525    }
1526
1527    #[test]
1528    fn oauth_callback_page_tells_user_to_close_success_tab() {
1529        let page = super::oauth_callback_page(
1530            true,
1531            "OAuth setup complete",
1532            "OAuth setup complete for messaging-slack. You can close this tab.",
1533        );
1534
1535        assert!(page.contains("window.close()"));
1536        assert!(page.contains("You can close this tab"));
1537    }
1538
1539    #[tokio::test]
1540    async fn persist_ui_draft_writes_provider_answers_to_dev_store() {
1541        let temp = tempfile::tempdir().expect("tempdir");
1542        let bundle_root = temp.path();
1543        std::fs::create_dir_all(bundle_root.join("packs")).expect("packs dir");
1544
1545        let pack_path = bundle_root.join("packs").join("weatherapi-pack.gtpack");
1546        write_pack_with_secret_requirements(
1547            &pack_path,
1548            "weatherapi-pack",
1549            r#"[{"key":"auth.param.get_weather.key"}]"#,
1550        )
1551        .expect("pack");
1552
1553        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1554            "weatherapi-pack": {
1555                "auth_param_get_weather_key": "test-weather-key"
1556            }
1557        }))
1558        .expect("answers");
1559
1560        let persisted = persist_ui_draft(bundle_root, "dev-tenant", None, "dev", &answers)
1561            .await
1562            .expect("persist draft");
1563        assert_eq!(
1564            persisted.get("weatherapi-pack"),
1565            Some(&json!(["auth_param_get_weather_key"]))
1566        );
1567
1568        let store = open_dev_store(bundle_root).expect("open store");
1569        let base_uri = crate::canonical_secret_uri(
1570            "dev",
1571            "dev-tenant",
1572            None,
1573            "weatherapi-pack",
1574            "auth_param_get_weather_key",
1575        );
1576        let alias_uri = crate::canonical_secret_uri(
1577            "dev",
1578            "dev-tenant",
1579            None,
1580            "weatherapi-pack",
1581            "auth.param.get_weather.key",
1582        );
1583        let base_value =
1584            String::from_utf8(store.get(&base_uri).await.expect("base")).expect("base utf8");
1585        let alias_value =
1586            String::from_utf8(store.get(&alias_uri).await.expect("alias")).expect("alias utf8");
1587        assert_eq!(base_value, "test-weather-key");
1588        assert_eq!(alias_value, "test-weather-key");
1589    }
1590
1591    #[test]
1592    fn detects_cloud_deploy_targets_in_prefill_answers() {
1593        let cloud_prefill = serde_json::from_value::<JsonMap<String, Value>>(json!({
1594            "platform_setup": {
1595                "deployment_targets": [
1596                    { "target": "runtime" },
1597                    { "target": "aws" }
1598                ]
1599            }
1600        }))
1601        .expect("cloud prefill");
1602        assert!(prefill_has_cloud_deployment_targets(Some(&cloud_prefill)));
1603
1604        let local_prefill = serde_json::from_value::<JsonMap<String, Value>>(json!({
1605            "platform_setup": {
1606                "deployment_targets": [
1607                    { "target": "runtime" },
1608                    { "target": "single-vm" }
1609                ]
1610            }
1611        }))
1612        .expect("local prefill");
1613        assert!(!prefill_has_cloud_deployment_targets(Some(&local_prefill)));
1614    }
1615}