1mod 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
34struct 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 prefill_answers: Option<JsonMap<String, Value>>,
46 scope_from_answers: bool,
49 shutdown_tx: broadcast::Sender<()>,
50 #[allow(dead_code)]
51 result: Mutex<Option<ExecutionResult>>,
52 self_port: u16,
55 oauth_pending: Mutex<HashMap<String, OauthPending>>,
57 oauth_connected: Mutex<HashSet<String>>,
59}
60
61struct 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
79fn 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
145struct 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#[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 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
271async 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
297const 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 let detected_tenant = detect_tenant_from_bundle(bundle_path);
350
351 let effective_tenant = if state.scope_from_answers {
354 cli_tenant.clone()
355 } else if cli_tenant == "demo" {
356 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
374fn 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
395async fn get_existing_scopes(State(state): State<std::sync::Arc<UiState>>) -> Json<Value> {
401 let bundle_path = &state.bundle_path;
402
403 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 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 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 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 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; }
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 let locale = query.locale.as_deref().or(state.locale.as_deref());
507
508 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 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 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 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 let prefill = &state.prefill_answers;
602
603 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 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 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 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 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 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 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 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
854const DEFAULT_AUTHORIZE_URL: &str = "https://app.hubspot.com/oauth/authorize";
858const DEFAULT_TOKEN_URL: &str = "https://api.hubapi.com/oauth/v1/token";
860const 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
863static 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
887async 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 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
962async 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 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 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
1095async 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
1151async 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 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
1170fn encode(value: &str) -> String {
1172 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
1173}
1174
1175fn esc_html(value: &str) -> String {
1177 value
1178 .replace('&', "&")
1179 .replace('<', "<")
1180 .replace('>', ">")
1181}
1182
1183fn 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
1195fn 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
1219fn 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 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 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
1321async 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
1399fn 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 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}