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