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    SavedLogin {
35        email: email.trim().to_string(),
36        password: password.to_string(),
37        api_base: Some(api_base),
38    }
39    .save()?;
40    Ok(())
41}
42
43/// `POST /v1/auth/register`, then login.
44pub async fn register(
45    email: &str,
46    password: &str,
47    display_name: &str,
48    api: &str,
49) -> anyhow::Result<()> {
50    if email.trim().is_empty() || password.is_empty() || display_name.trim().is_empty() {
51        anyhow::bail!("email, password, and display name are required");
52    }
53    let client = ControlPlaneClient::new(api);
54    let _: serde_json::Value = client
55        .post(
56            "/v1/auth/register",
57            &serde_json::json!({
58                "email": email.trim(),
59                "password": password,
60                "display_name": display_name.trim(),
61            }),
62            None,
63        )
64        .await?;
65    login(email, password, api).await
66}
67
68/// Login using the default API base from client config / env.
69pub async fn login_default(email: &str, password: &str) -> anyhow::Result<()> {
70    let api = SavedLogin::load()
71        .and_then(|s| s.api_base)
72        .unwrap_or_else(default_api_base);
73    login(email, password, &api).await
74}
75
76/// Register using the default API base from client config / env.
77pub async fn register_default(
78    email: &str,
79    password: &str,
80    display_name: &str,
81) -> anyhow::Result<()> {
82    let api = SavedLogin::load()
83        .and_then(|s| s.api_base)
84        .unwrap_or_else(default_api_base);
85    register(email, password, display_name, &api).await
86}
87
88/// True when an API token is set (skip interactive onboarding).
89pub fn has_api_token() -> bool {
90    std::env::var("FLATLAND_API_TOKEN")
91        .map(|t| !t.is_empty())
92        .unwrap_or(false)
93}
94
95/// Probe control plane: env API token, or `GET /v1/me` with the saved session.
96pub async fn session_is_valid() -> bool {
97    if has_api_token() {
98        return true;
99    }
100    if !credentials_available() {
101        return false;
102    }
103    let Ok(auth) = load_play_auth() else {
104        return false;
105    };
106    if auth.kind == PlayAuthKind::ApiToken {
107        return true;
108    }
109    let client = ControlPlaneClient::new(&auth.api_base);
110    client
111        .get::<serde_json::Value>("/v1/me", &auth.bearer)
112        .await
113        .is_ok()
114}
115
116/// When `session.json` exists but the server rejected the token (common after
117/// Redis/dev restarts), re-login with [`SavedLogin`] credentials.
118pub async fn try_restore_expired_session() -> bool {
119    if session_is_valid().await {
120        return true;
121    }
122    if !SessionConfig::exists() {
123        return false;
124    }
125    let Some(saved) = SavedLogin::load() else {
126        return false;
127    };
128    if saved.email.trim().is_empty() || saved.password.is_empty() {
129        return false;
130    }
131    let api = saved
132        .api_base
133        .clone()
134        .unwrap_or_else(default_api_base);
135    login(&saved.email, &saved.password, &api).await.is_ok()
136}
137
138/// Interactive login/register is needed when there is no usable session/API token.
139pub async fn needs_interactive_login() -> bool {
140    if has_api_token() {
141        return false;
142    }
143    if session_is_valid().await {
144        return false;
145    }
146    // Stale session + remembered password → silent restore, no form.
147    if try_restore_expired_session().await {
148        return false;
149    }
150    true
151}
152
153/// Revoke the control-plane session (best-effort) and delete local `session.json`.
154/// Keeps [`SavedLogin`] so the next login form can autofill.
155pub async fn logout() -> anyhow::Result<()> {
156    if let Ok(session) = SessionConfig::load() {
157        let client = ControlPlaneClient::new(&session.api_base);
158        let _ = client
159            .post::<serde_json::Value, _>(
160                "/v1/auth/logout",
161                &serde_json::json!({}),
162                Some(&session.session_token),
163            )
164            .await;
165    }
166    SessionConfig::clear()?;
167    Ok(())
168}