Skip to main content

systemprompt_cli/commands/cloud/auth/
login.rs

1//! Interactive OAuth login against systemprompt.io Cloud.
2//!
3//! Runs the provider-selection prompt and browser OAuth flow, persists the
4//! returned credentials and tenant list to the local cloud config paths, and
5//! syncs the authenticated admin user into all profiles.
6
7use anyhow::{Result, anyhow};
8use systemprompt_cloud::{
9    CloudApiClient, CloudCredentials, CloudPath, OAuthTemplates, TenantInfo, TenantStore,
10    UserMeResponse, get_cloud_paths, run_oauth_flow,
11};
12use systemprompt_logging::CliService;
13use systemprompt_models::modules::ApiPaths;
14
15use crate::cli_settings::CliConfig;
16use crate::cloud::templates::{AUTH_ERROR_HTML, AUTH_SUCCESS_HTML};
17use crate::cloud::types::{
18    LoginCustomerInfo, LoginOutput, LoginTenantInfo, LoginUserInfo, TenantPlanInfo,
19};
20use crate::cloud::{Environment, OAuthProvider};
21use crate::interactive::Prompter;
22use crate::shared::CommandOutput;
23
24pub(super) async fn execute(
25    environment: Environment,
26    prompter: &dyn Prompter,
27    config: &CliConfig,
28) -> Result<CommandOutput> {
29    if !config.is_interactive() {
30        return Err(anyhow!(
31            "OAuth login requires interactive mode.\n\nAlternatives:\n- Set \
32             SYSTEMPROMPT_CLOUD_TOKEN environment variable"
33        ));
34    }
35
36    let api_url = environment.api_url();
37
38    CliService::section("systemprompt.io Cloud Login");
39    CliService::info(&format!("Environment: {:?}", environment));
40
41    let cloud_paths = get_cloud_paths();
42
43    if cloud_paths.exists(CloudPath::Credentials) {
44        let creds_path = cloud_paths.resolve(CloudPath::Credentials);
45        let existing = CloudCredentials::load_from_path(&creds_path)?;
46        CliService::warning(&format!("Already logged in as: {}", existing.user_email));
47        CliService::info("Re-authenticating...");
48    }
49
50    let providers = [OAuthProvider::Github, OAuthProvider::Google];
51    let provider_names: Vec<&str> = providers.iter().map(OAuthProvider::display_name).collect();
52
53    let provider_options: Vec<String> = provider_names.iter().map(|s| (*s).to_owned()).collect();
54    let selection = prompter.select("Select authentication provider", &provider_options)?;
55
56    let provider = providers[selection];
57
58    let templates = OAuthTemplates {
59        success_html: AUTH_SUCCESS_HTML,
60        error_html: AUTH_ERROR_HTML,
61    };
62    let token = run_oauth_flow(api_url, provider, templates).await?;
63
64    complete_login(api_url, token, config).await
65}
66
67pub async fn complete_login(
68    api_url: &str,
69    token: String,
70    config: &CliConfig,
71) -> Result<CommandOutput> {
72    let cloud_paths = get_cloud_paths();
73
74    let spinner = CliService::spinner("Verifying token...");
75    let client = CloudApiClient::new(api_url, &token)?;
76    let response = client.get_user().await?;
77    spinner.finish_and_clear();
78
79    let creds = CloudCredentials::new(
80        systemprompt_identifiers::CloudAuthToken::new(token),
81        api_url.to_owned(),
82        systemprompt_identifiers::Email::new(response.user.email.clone()),
83    );
84
85    let save_path = cloud_paths.resolve(CloudPath::Credentials);
86    creds.save_to_path(&save_path)?;
87    CliService::key_value("Credentials saved to", &save_path.display().to_string());
88
89    let tenant_store = TenantStore::from_tenant_infos(&response.tenants);
90    let tenants_path = cloud_paths.resolve(CloudPath::Tenants);
91    tenant_store.save_to_path(&tenants_path)?;
92    CliService::key_value("Tenants synced to", &tenants_path.display().to_string());
93
94    CliService::success("Logged in successfully");
95
96    let activity_user_id = systemprompt_identifiers::UserId::new(response.user.id.clone());
97    if let Err(e) = client
98        .report_activity(ApiPaths::ACTIVITY_EVENT_LOGIN, &activity_user_id)
99        .await
100    {
101        tracing::debug!(error = %e, "Failed to report login activity");
102    }
103
104    CliService::section("Syncing Admin User to Profiles");
105    if let Some(cloud_user) = crate::cloud::sync::admin_user::CloudUser::from_credentials()? {
106        let verbose = config.should_show_verbose();
107        let results =
108            crate::cloud::sync::admin_user::sync_admin_to_all_profiles(&cloud_user, verbose).await;
109        crate::cloud::sync::admin_user::print_sync_results(&results);
110    } else {
111        CliService::warning("Could not load cloud user for admin sync");
112    }
113
114    print_login_result(&response);
115
116    let output = build_login_output(&response, &save_path, &tenants_path);
117
118    Ok(CommandOutput::card_value("Cloud Login", &output).with_skip_render())
119}
120
121fn build_login_output(
122    response: &UserMeResponse,
123    credentials_path: &std::path::Path,
124    tenants_path: &std::path::Path,
125) -> LoginOutput {
126    let user = LoginUserInfo {
127        id: response.user.id.as_str().to_owned(),
128        email: response.user.email.clone(),
129        name: response.user.name.clone(),
130    };
131
132    let customer = response
133        .customer
134        .as_ref()
135        .map(|c| LoginCustomerInfo { id: c.id.clone() });
136
137    let tenants: Vec<LoginTenantInfo> = response
138        .tenants
139        .iter()
140        .map(|t| LoginTenantInfo {
141            id: t.id.clone(),
142            name: t.name.clone(),
143            subscription_status: t.subscription_status.map(|s| format!("{s:?}")),
144            plan: t.plan.as_ref().map(|p| TenantPlanInfo {
145                name: p.name.clone(),
146                memory_mb: p.memory_mb,
147                volume_gb: p.volume_gb,
148            }),
149            region: t.region.clone(),
150            hostname: t.hostname.clone(),
151        })
152        .collect();
153
154    LoginOutput {
155        user,
156        customer,
157        tenants,
158        credentials_path: credentials_path.display().to_string(),
159        tenants_path: tenants_path.display().to_string(),
160    }
161}
162
163fn print_login_result(response: &UserMeResponse) {
164    CliService::section("User");
165    CliService::key_value("Email", &response.user.email);
166    if let Some(name) = &response.user.name {
167        CliService::key_value("Name", name);
168    }
169    CliService::key_value("ID", response.user.id.as_str());
170
171    if let Some(customer) = &response.customer {
172        CliService::section("Customer");
173        CliService::key_value("ID", &customer.id);
174    }
175
176    print_tenants(&response.tenants);
177}
178
179fn print_tenants(tenants: &[TenantInfo]) {
180    if tenants.is_empty() {
181        CliService::info("No cloud tenants found.");
182        CliService::info(
183            "Run 'systemprompt cloud tenant create' (or 'just tenant') to create a local tenant.",
184        );
185        return;
186    }
187
188    CliService::section("Available Tenants");
189    for tenant in tenants {
190        let status_str = tenant
191            .subscription_status
192            .map_or_else(|| "Unknown".to_owned(), |s| format!("{s:?}"));
193        CliService::key_value(&tenant.name, &status_str);
194        if let Some(plan) = &tenant.plan {
195            CliService::info(&format!(
196                "  Plan: {} ({}MB RAM, {}GB storage)",
197                plan.name, plan.memory_mb, plan.volume_gb
198            ));
199        }
200        if let Some(region) = &tenant.region {
201            CliService::info(&format!("  Region: {region}"));
202        }
203        if let Some(hostname) = &tenant.hostname {
204            CliService::info(&format!("  URL: https://{hostname}"));
205        }
206    }
207    CliService::info("");
208    CliService::info(
209        "Run 'systemprompt cloud tenant create' (or 'just tenant') to add a local tenant,",
210    );
211    CliService::info("then 'systemprompt cloud profile create <name>' to create a profile.");
212}