Skip to main content

flatland_cli/
session_auth.rs

1//! Shared login / register / session probe for CLI and gfx clients.
2
3use crate::api::ControlPlaneClient;
4use crate::auth_context::{credentials_available, load_play_auth, PlayAuthKind};
5use crate::config::{default_api_base, SavedLogin, SessionConfig};
6
7#[derive(serde::Deserialize)]
8struct LoginData {
9    session_token: String,
10}
11
12/// `POST /v1/auth/login` and save [`SessionConfig`] + [`SavedLogin`].
13pub async fn login(email: &str, password: &str, api: &str) -> anyhow::Result<()> {
14    if email.trim().is_empty() || password.is_empty() {
15        anyhow::bail!("email and password are required");
16    }
17    let client = ControlPlaneClient::new(api);
18    let data: LoginData = client
19        .post(
20            "/v1/auth/login",
21            &serde_json::json!({ "email": email.trim(), "password": password }),
22            None,
23        )
24        .await?;
25    // Keep last character across re-login (Redis/dev restarts wipe the server session).
26    let last_character_id = SessionConfig::load().ok().and_then(|s| s.last_character_id);
27    let api_base = api.trim_end_matches('/').to_string();
28    let config = SessionConfig {
29        api_base: api_base.clone(),
30        session_token: data.session_token,
31        last_character_id,
32    };
33    config.save()?;
34    // One-shot --host must not rewrite login.json's remembered API base (local
35    // play still uses client.json / default after this process exits).
36    let saved_api_base = if flatland_client_lib::host_override_active() {
37        SavedLogin::load().and_then(|s| s.api_base)
38    } else {
39        Some(api_base)
40    };
41    SavedLogin {
42        email: email.trim().to_string(),
43        password: password.to_string(),
44        api_base: saved_api_base,
45    }
46    .save()?;
47    Ok(())
48}
49
50/// `POST /v1/auth/register`, then login.
51pub async fn register(
52    email: &str,
53    password: &str,
54    display_name: &str,
55    api: &str,
56) -> anyhow::Result<()> {
57    if email.trim().is_empty() || password.is_empty() || display_name.trim().is_empty() {
58        anyhow::bail!("email, password, and display name are required");
59    }
60    let client = ControlPlaneClient::new(api);
61    let _: serde_json::Value = client
62        .post(
63            "/v1/auth/register",
64            &serde_json::json!({
65                "email": email.trim(),
66                "password": password,
67                "display_name": display_name.trim(),
68            }),
69            None,
70        )
71        .await?;
72    login(email, password, api).await
73}
74
75/// Login using the default API base from client config / env.
76pub async fn login_default(email: &str, password: &str) -> anyhow::Result<()> {
77    let api = SavedLogin::load()
78        .and_then(|s| s.api_base)
79        .unwrap_or_else(default_api_base);
80    login(email, password, &api).await
81}
82
83/// Register using the default API base from client config / env.
84pub async fn register_default(
85    email: &str,
86    password: &str,
87    display_name: &str,
88) -> anyhow::Result<()> {
89    let api = SavedLogin::load()
90        .and_then(|s| s.api_base)
91        .unwrap_or_else(default_api_base);
92    register(email, password, display_name, &api).await
93}
94
95/// True when an API token is set (skip interactive onboarding).
96pub fn has_api_token() -> bool {
97    std::env::var("FLATLAND_API_TOKEN")
98        .map(|t| !t.is_empty())
99        .unwrap_or(false)
100}
101
102/// Probe control plane: env API token, or `GET /v1/me` with the saved session.
103pub async fn session_is_valid() -> bool {
104    if has_api_token() {
105        return true;
106    }
107    if !credentials_available() {
108        return false;
109    }
110    let Ok(auth) = load_play_auth() else {
111        return false;
112    };
113    if auth.kind == PlayAuthKind::ApiToken {
114        return true;
115    }
116    let client = ControlPlaneClient::new(&auth.api_base);
117    client
118        .get::<serde_json::Value>("/v1/me", &auth.bearer)
119        .await
120        .is_ok()
121}
122
123/// When `session.json` exists but the server rejected the token (common after
124/// Redis/dev restarts), re-login with [`SavedLogin`] credentials.
125pub async fn try_restore_expired_session() -> bool {
126    if session_is_valid().await {
127        return true;
128    }
129    if !SessionConfig::exists() {
130        return false;
131    }
132    let Some(saved) = SavedLogin::load() else {
133        return false;
134    };
135    if saved.email.trim().is_empty() || saved.password.is_empty() {
136        return false;
137    }
138    let api = saved
139        .api_base
140        .clone()
141        .unwrap_or_else(default_api_base);
142    login(&saved.email, &saved.password, &api).await.is_ok()
143}
144
145/// Interactive login/register is needed when there is no usable session/API token.
146pub async fn needs_interactive_login() -> bool {
147    if has_api_token() {
148        return false;
149    }
150    if session_is_valid().await {
151        return false;
152    }
153    // Stale session + remembered password → silent restore, no form.
154    if try_restore_expired_session().await {
155        return false;
156    }
157    true
158}
159
160/// Revoke the control-plane session (best-effort) and delete local `session.json`.
161/// Keeps [`SavedLogin`] so the next login form can autofill.
162pub async fn logout() -> anyhow::Result<()> {
163    if let Ok(session) = SessionConfig::load() {
164        let client = ControlPlaneClient::new(&session.api_base);
165        let _ = client
166            .post::<serde_json::Value, _>(
167                "/v1/auth/logout",
168                &serde_json::json!({}),
169                Some(&session.session_token),
170            )
171            .await;
172    }
173    SessionConfig::clear()?;
174    Ok(())
175}