Skip to content

Commit 78c8acd

Browse files
Improve login/logout CLI UX (#4367)
## Summary Improves the `spacetime login` and `spacetime logout` UX to behave more like standard CLI tools. ### `spacetime login` **Before:** If already logged in, prints "You are already logged in" and exits. User must manually run `logout` first. **After:** If already logged in, automatically logs out the previous session and proceeds with a fresh login. Prints the old identity being logged out and the new identity on success. ``` $ spacetime login Logged out of previous session (identity 0xabc...). Opening https://spacetimedb.com/login/cli?token=... in your browser. Waiting to hear response from the server... Logged in with identity 0xdef... ``` ### `spacetime logout` **Before:** No output on success. Hard failure if offline. **After:** - Prints confirmation: `Logged out (identity 0xabc...).` - Prints `You are not logged in.` if already logged out - Best-effort server-side session invalidation with 5s timeout (prints warning if offline instead of failing) ### Changes - `login.rs`: Remove `spacetimedb_token_cached` early-return; instead log out previous session and proceed. Show identity on login success. - `logout.rs`: Add identity display, not-logged-in check, 5s timeout for server call with warning on failure. Note: This subsumes the offline fix from #4361. --------- Signed-off-by: Zeke Foppa <196249+bfops@users.noreply.github.com> Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com> Co-authored-by: Zeke Foppa <bfops@users.noreply.github.com> Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
1 parent 5404126 commit 78c8acd

6 files changed

Lines changed: 239 additions & 47 deletions

File tree

crates/cli/src/subcommands/init.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use toml_edit::{value, DocumentMut, Item};
1919
use xmltree::{Element, XMLNode};
2020

2121
use crate::spacetime_config::{PackageManager, SpacetimeConfig, CONFIG_FILENAME};
22-
use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST};
22+
use crate::subcommands::login::{spacetimedb_login_and_save, DEFAULT_AUTH_HOST};
2323

2424
mod embedded {
2525
use spacetimedb_data_structures::map::HashCollectionExt as _;
@@ -214,7 +214,7 @@ pub async fn check_and_prompt_login(config: &mut Config) -> anyhow::Result<bool>
214214

215215
if should_login {
216216
let host = Url::parse(DEFAULT_AUTH_HOST)?;
217-
spacetimedb_login_force(config, &host, false, true).await?;
217+
spacetimedb_login_and_save(config, &host, false, true).await?;
218218
println!("{}", "Successfully logged in!".green());
219219
Ok(true)
220220
} else {

crates/cli/src/subcommands/login.rs

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
use crate::util::decode_identity;
21
use crate::Config;
2+
use crate::{logout::ensure_logged_out, util::decode_identity};
33
use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
44
use reqwest::Url;
55
use serde::Deserialize;
@@ -62,17 +62,22 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
6262
let server_issued_login: Option<&String> = args.get_one("server");
6363
let open_browser = !args.get_flag("no-browser");
6464

65+
let _was_logged_in = ensure_logged_out(&mut config, &host).await;
66+
6567
if let Some(token) = spacetimedb_token {
6668
config.set_spacetimedb_token(token.clone());
6769
config.save();
68-
return Ok(());
70+
match decode_identity(token) {
71+
Ok(identity) => println!("Logged in with identity {identity}"),
72+
Err(_) => println!("Token saved."),
73+
}
6974
}
7075

7176
if let Some(server) = server_issued_login {
7277
let host = Url::parse(&config.get_host_url(Some(server))?)?;
73-
spacetimedb_token_cached(&mut config, &host, true, open_browser).await?;
78+
spacetimedb_login_and_save(&mut config, &host, true, open_browser).await?;
7479
} else {
75-
spacetimedb_token_cached(&mut config, &host, false, open_browser).await?;
80+
spacetimedb_login_and_save(&mut config, &host, false, open_browser).await?;
7681
}
7782

7883
Ok(())
@@ -105,24 +110,7 @@ async fn exec_show(config: Config, args: &ArgMatches) -> Result<(), anyhow::Erro
105110
Ok(())
106111
}
107112

108-
async fn spacetimedb_token_cached(
109-
config: &mut Config,
110-
host: &Url,
111-
direct_login: bool,
112-
open_browser: bool,
113-
) -> anyhow::Result<String> {
114-
// Currently, this token does not expire. However, it will at some point in the future. When that happens,
115-
// this code will need to happen before any request to a spacetimedb server, rather than at the end of the login flow here.
116-
if let Some(token) = config.spacetimedb_token() {
117-
println!("You are already logged in.");
118-
println!("If you want to log out, use spacetime logout.");
119-
Ok(token.clone())
120-
} else {
121-
spacetimedb_login_force(config, host, direct_login, open_browser).await
122-
}
123-
}
124-
125-
pub async fn spacetimedb_login_force(
113+
pub async fn spacetimedb_login_and_save(
126114
config: &mut Config,
127115
host: &Url,
128116
direct_login: bool,
@@ -134,16 +122,19 @@ pub async fn spacetimedb_login_force(
134122
println!("WARNING: This login will NOT work for any other servers.");
135123
token
136124
} else {
137-
let session_token = web_login_cached(config, host, open_browser).await?;
125+
let session_token = web_login_or_cached(config, host, open_browser).await?;
138126
spacetimedb_login(host, &session_token).await?
139127
};
140128
config.set_spacetimedb_token(token.clone());
141129
config.save();
142130

131+
let identity = decode_identity(&token)?;
132+
println!("Logged in with identity {identity}");
133+
143134
Ok(token)
144135
}
145136

146-
async fn web_login_cached(config: &mut Config, host: &Url, open_browser: bool) -> anyhow::Result<String> {
137+
async fn web_login_or_cached(config: &mut Config, host: &Url, open_browser: bool) -> anyhow::Result<String> {
147138
if let Some(session_token) = config.web_session_token() {
148139
// Currently, these session tokens do not expire. At some point in the future, we may also need to check this session token for validity.
149140
Ok(session_token.clone())
Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
use std::time::Duration;
2-
1+
use crate::util::decode_identity;
32
use crate::Config;
43
use clap::{Arg, ArgMatches, Command};
54
use reqwest::Url;
5+
use std::time::Duration;
66

77
pub fn cli() -> Command {
88
Command::new("logout").arg(
@@ -14,27 +14,55 @@ pub fn cli() -> Command {
1414
}
1515

1616
pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
17+
// Check if already logged out.
18+
if config.spacetimedb_token().is_none() && config.web_session_token().is_none() {
19+
println!("You are not logged in.");
20+
return Ok(());
21+
}
22+
1723
let host: &String = args.get_one("auth-host").unwrap();
1824
let host = Url::parse(host)?;
1925

20-
if let Some(web_session_token) = config.web_session_token() {
21-
let client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build()?;
22-
let result = client
23-
.post(host.join("auth/cli/logout")?)
24-
.header("Authorization", format!("Bearer {web_session_token}"))
25-
.send()
26-
.await;
27-
28-
if let Err(e) = result {
29-
eprintln!(
30-
"Warning: Could not reach auth server to invalidate session: {e}\n\
31-
Local credentials have been cleared."
32-
);
33-
}
34-
}
26+
let _ = ensure_logged_out(&mut config, &host).await;
27+
28+
Ok(())
29+
}
3530

31+
async fn server_logout(config: &mut Config, host: &Url) -> Result<(), anyhow::Error> {
32+
let Some(web_session_token) = config.web_session_token() else {
33+
anyhow::bail!("No web session token");
34+
};
35+
// Best-effort server-side session invalidation.
36+
let client = reqwest::Client::builder().timeout(Duration::from_secs(5)).build()?;
37+
client
38+
.post(host.join("auth/cli/logout")?)
39+
.header("Authorization", format!("Bearer {web_session_token}"))
40+
.send()
41+
.await?;
42+
Ok(())
43+
}
44+
45+
/// Logs out the user from the specified auth server.
46+
/// Returns true if the user was logged out, false if they were not logged in.
47+
pub async fn ensure_logged_out(config: &mut Config, host: &Url) -> bool {
48+
let Some(token) = config.spacetimedb_token() else {
49+
return false;
50+
};
51+
// Grab identity before clearing tokens.
52+
let identity = decode_identity(token).ok();
53+
54+
// Best-effort server-side session invalidation.
55+
if let Err(e) = server_logout(config, host).await {
56+
eprintln!("Warning: Failed to logout from auth server: {e}\nLocal credentials have been cleared.");
57+
}
3658
config.clear_login_tokens();
3759
config.save();
3860

39-
Ok(())
61+
if let Some(id) = identity {
62+
println!("Logged out (identity {id}).");
63+
} else {
64+
println!("Logged out.");
65+
}
66+
67+
true
4068
}

crates/cli/src/util.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::io::Write;
88
use std::path::{Path, PathBuf};
99

1010
use crate::config::Config;
11-
use crate::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST};
11+
use crate::login::{spacetimedb_login_and_save, DEFAULT_AUTH_HOST};
1212

1313
pub const UNSTABLE_WARNING: &str = "WARNING: This command is UNSTABLE and subject to breaking changes.";
1414

@@ -357,10 +357,10 @@ pub async fn get_login_token_or_log_in(
357357

358358
if full_login {
359359
let host = Url::parse(DEFAULT_AUTH_HOST)?;
360-
spacetimedb_login_force(config, &host, false, true).await
360+
spacetimedb_login_and_save(config, &host, false, true).await
361361
} else {
362362
let host = Url::parse(&config.get_host_url(target_server)?)?;
363-
spacetimedb_login_force(config, &host, true, true).await
363+
spacetimedb_login_and_save(config, &host, true, true).await
364364
}
365365
}
366366

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
//! CLI auth command tests (`login` / `logout`)
2+
3+
use spacetimedb_smoketests::{require_local_server, Smoketest};
4+
use std::fs;
5+
use std::process::Output;
6+
7+
fn output_stdout(output: &Output) -> String {
8+
String::from_utf8_lossy(&output.stdout).to_string()
9+
}
10+
11+
fn output_stderr(output: &Output) -> String {
12+
String::from_utf8_lossy(&output.stderr).to_string()
13+
}
14+
15+
fn assert_success(output: &Output, context: &str) {
16+
assert!(
17+
output.status.success(),
18+
"{context} failed:\nstdout: {}\nstderr: {}",
19+
output_stdout(output),
20+
output_stderr(output),
21+
);
22+
}
23+
24+
fn read_config(test: &Smoketest) -> toml::Table {
25+
let raw = fs::read_to_string(&test.config_path).expect("Failed to read config");
26+
raw.parse::<toml::Table>().expect("Failed to parse config")
27+
}
28+
29+
fn write_config(test: &Smoketest, config: &toml::Table) {
30+
let raw = toml::to_string(config).expect("Failed to serialize config");
31+
fs::write(&test.config_path, raw).expect("Failed to write config");
32+
}
33+
34+
#[test]
35+
fn cli_logout_removes_cached_tokens() {
36+
require_local_server!();
37+
let test = Smoketest::builder().autopublish(false).build();
38+
39+
let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]);
40+
assert_success(&login, "initial login");
41+
42+
// Simulate a cached web session token; logout should clear both token fields.
43+
let mut config = read_config(&test);
44+
config.insert(
45+
"web_session_token".to_string(),
46+
toml::Value::String("fake-web-session-token".to_string()),
47+
);
48+
write_config(&test, &config);
49+
50+
let logout = test.spacetime_cmd(&["logout"]);
51+
assert_success(&logout, "logout");
52+
assert!(
53+
output_stdout(&logout).contains("Logged out (identity "),
54+
"logout stdout should include identity message:\n{}",
55+
output_stdout(&logout),
56+
);
57+
58+
let config_after = read_config(&test);
59+
assert!(
60+
config_after.get("spacetimedb_token").is_none(),
61+
"spacetimedb_token should be removed after logout: {:?}",
62+
config_after.get("spacetimedb_token")
63+
);
64+
assert!(
65+
config_after.get("web_session_token").is_none(),
66+
"web_session_token should be removed after logout: {:?}",
67+
config_after.get("web_session_token")
68+
);
69+
}
70+
71+
#[test]
72+
// Even if there's no web session, logout still removes the SpacetimeDB token
73+
fn cli_logout_removes_cached_tokens_without_web_token() {
74+
require_local_server!();
75+
let test = Smoketest::builder().autopublish(false).build();
76+
77+
let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]);
78+
assert_success(&login, "initial login");
79+
80+
let logout = test.spacetime_cmd(&["logout"]);
81+
assert_success(&logout, "logout");
82+
assert!(
83+
output_stdout(&logout).contains("Logged out (identity "),
84+
"logout stdout should include identity message:\n{}",
85+
output_stdout(&logout),
86+
);
87+
88+
let config_after = read_config(&test);
89+
assert!(
90+
config_after.get("spacetimedb_token").is_none(),
91+
"spacetimedb_token should be removed after logout: {:?}",
92+
config_after.get("spacetimedb_token")
93+
);
94+
assert!(
95+
config_after.get("web_session_token").is_none(),
96+
"web_session_token should be removed after logout: {:?}",
97+
config_after.get("web_session_token")
98+
);
99+
}
100+
101+
#[test]
102+
fn cli_logout_is_idempotent() {
103+
require_local_server!();
104+
let test = Smoketest::builder().autopublish(false).build();
105+
106+
let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]);
107+
assert_success(&login, "initial login");
108+
109+
let first_logout = test.spacetime_cmd(&["logout"]);
110+
assert_success(&first_logout, "first logout");
111+
assert!(
112+
output_stdout(&first_logout).contains("Logged out "),
113+
"first logout should report logged-out:\n{}",
114+
output_stdout(&first_logout)
115+
);
116+
117+
let second_logout = test.spacetime_cmd(&["logout"]);
118+
assert_success(&second_logout, "second logout");
119+
assert!(
120+
output_stdout(&second_logout).contains("You are not logged in."),
121+
"second logout should report not logged in:\n{}",
122+
output_stdout(&second_logout)
123+
);
124+
}
125+
126+
#[test]
127+
fn cli_direct_login_works_and_shows_core_messages() {
128+
require_local_server!();
129+
let test = Smoketest::builder().autopublish(false).build();
130+
131+
let login = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]);
132+
assert_success(&login, "direct login");
133+
134+
let login_stdout = output_stdout(&login);
135+
assert!(
136+
login_stdout.contains("Logged in "),
137+
"direct login stdout missing confirmation:\n{}",
138+
login_stdout
139+
);
140+
141+
let show = test.spacetime_cmd(&["login", "show"]);
142+
assert_success(&show, "login show");
143+
assert!(
144+
output_stdout(&show).contains("You are logged in as "),
145+
"login show should report current identity:\n{}",
146+
output_stdout(&show)
147+
);
148+
}
149+
150+
#[test]
151+
fn cli_logging_in_twice_works() {
152+
require_local_server!();
153+
let test = Smoketest::builder().autopublish(false).build();
154+
155+
let first = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]);
156+
assert_success(&first, "first login");
157+
158+
let second = test.spacetime_cmd(&["login", "--server-issued-login", &test.server_url]);
159+
assert_success(&second, "second login");
160+
161+
let second_stdout = output_stdout(&second);
162+
assert!(
163+
second_stdout.contains("Logged out (identity "),
164+
"second login should log out previous identity first:\n{}",
165+
second_stdout
166+
);
167+
assert!(
168+
second_stdout.contains("Logged in with identity "),
169+
"second login should complete with a new login:\n{}",
170+
second_stdout
171+
);
172+
}

crates/smoketests/tests/smoketests/cli/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod auth;
12
pub mod dev;
23
pub mod generate;
34
pub mod publish;

0 commit comments

Comments
 (0)