Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 94 additions & 14 deletions crates/cli/src/auth/login.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use anyhow::{Context, Result, anyhow};
use clap::Args;
use serde::Serialize;
use std::io::{self, Write};
use textbin_client::Client;
use textbin_client::{ApiTokenMetadata, AuthenticatedUser, Client, Identity};

use crate::output::print_json;
use crate::settings::{Settings, environment_token};

#[derive(Args)]
Expand All @@ -22,6 +24,29 @@ pub struct LoginArgs {
/// Import an existing API token through a hidden prompt
#[arg(long)]
with_token: bool,

/// Print the outcome as JSON; prompts still go to the terminal
#[arg(long, default_value_t = false)]
json: bool,
}

/// How the profile came to be authenticated. The API token itself is never
/// printed; scripts that need it should pass it in through TEXTBIN_TOKEN.
#[derive(Serialize)]
#[serde(rename_all = "snake_case")]
enum LoginStatus {
LoggedIn,
AlreadyLoggedIn,
Environment,
}

#[derive(Serialize)]
struct LoginOutput<'a> {
status: LoginStatus,
profile: &'a str,
server: &'a str,
user: &'a AuthenticatedUser,
token: &'a ApiTokenMetadata,
}

pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> {
Expand All @@ -41,12 +66,13 @@ pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> {
let client = Client::try_new(&server_url)?.with_api_token(Some(token));
let identity = client.identity()?;
settings.save_environment_login(&profile_name, &server_url, &identity)?;
print_identity(
"Authenticated through TEXTBIN_TOKEN",
return report(
args,
LoginStatus::Environment,
&profile_name,
&server_url,
&identity.user.email,
&identity,
);
return Ok(());
}

if args.server.is_none()
Expand All @@ -57,8 +83,13 @@ pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> {
Ok(identity) => {
settings.migrate_login(&profile_name, &identity)?;
settings.activate_profile(&profile_name)?;
print_identity("Already logged in", client.base_url(), &identity.user.email);
return Ok(());
return report(
args,
LoginStatus::AlreadyLoggedIn,
&profile_name,
client.base_url(),
&identity,
);
}
Err(error) if error.unauthorized() => {
settings.forget_login(&profile_name)?;
Expand Down Expand Up @@ -99,14 +130,20 @@ pub fn handle(args: &LoginArgs, settings: &Settings) -> Result<()> {
return Err(storage_error);
}

print_identity("Logged in", &server_url, &identity.user.email);

Ok(())
report(
args,
LoginStatus::LoggedIn,
&profile_name,
&server_url,
&identity,
)
}

// Prompts go to stderr so stdout carries only the result, which `--json`
// relies on. rpassword already reads the password from the terminal.
fn prompt(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush().context("could not write prompt")?;
eprint!("{label}");
io::stderr().flush().context("could not write prompt")?;

let mut value = String::new();
io::stdin()
Expand All @@ -115,8 +152,30 @@ fn prompt(label: &str) -> Result<String> {
Ok(value.trim().to_string())
}

fn print_identity(prefix: &str, server_url: &str, email: &str) {
println!("{prefix} as {email} on {server_url}");
fn report(
args: &LoginArgs,
status: LoginStatus,
profile: &str,
server: &str,
identity: &Identity,
) -> Result<()> {
if args.json {
return print_json(&LoginOutput {
status,
profile,
server,
user: &identity.user,
token: &identity.token,
});
}

let prefix = match status {
LoginStatus::LoggedIn => "Logged in",
LoginStatus::AlreadyLoggedIn => "Already logged in",
LoginStatus::Environment => "Authenticated through TEXTBIN_TOKEN",
};
println!("{prefix} as {} on {server}", identity.user.email);
Ok(())
}

#[cfg(test)]
Expand Down Expand Up @@ -159,6 +218,7 @@ mod tests {
profile: Some("demo".to_string()),
name: "Textbin CLI".to_string(),
with_token: false,
json: false,
},
&settings,
);
Expand All @@ -175,6 +235,25 @@ mod tests {
fs::remove_dir_all(path.parent().unwrap()).unwrap();
}

#[test]
fn json_output_names_the_outcome_without_the_secret() {
let identity = identity();
let output = serde_json::to_value(LoginOutput {
status: LoginStatus::AlreadyLoggedIn,
profile: "demo",
server: "https://textbin.example.com",
user: &identity.user,
token: &identity.token,
})
.unwrap();

assert_eq!(output["status"], "already_logged_in");
assert_eq!(output["profile"], "demo");
assert_eq!(output["user"]["email"], identity.user.email);
assert_eq!(output["token"]["id"], identity.token.id);
assert!(output["token"].get("api_token").is_none());
}

#[test]
fn environment_token_profile_becomes_active() {
let _environment = TEST_ENVIRONMENT.lock().unwrap();
Expand All @@ -200,6 +279,7 @@ mod tests {
profile: Some("demo".to_string()),
name: "Textbin CLI".to_string(),
with_token: false,
json: false,
},
&settings,
);
Expand Down
49 changes: 46 additions & 3 deletions crates/cli/src/auth/logout.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use anyhow::{Context, Result, bail};
use clap::Args;
use serde::Serialize;
use textbin_client::Client;

use crate::output::print_json;
use crate::settings::{Settings, environment_token};

#[derive(Args)]
Expand All @@ -13,6 +15,20 @@ pub struct LogoutArgs {
/// Configuration profile to log out
#[arg(long)]
profile: Option<String>,

/// Print the outcome as JSON
#[arg(long, default_value_t = false)]
json: bool,
}

/// `profile` is absent when TEXTBIN_TOKEN was revoked, since no stored
/// profile was involved; `server` is present only when a token was revoked.
#[derive(Serialize)]
struct LogoutOutput<'a> {
profile: Option<&'a str>,
server: Option<&'a str>,
revoked: bool,
logged_out: bool,
}

pub fn handle(args: &LogoutArgs, settings: &Settings) -> Result<()> {
Expand All @@ -34,6 +50,16 @@ pub fn handle(args: &LogoutArgs, settings: &Settings) -> Result<()> {
};
let client = Client::try_new(server_url)?.with_api_token(Some(token));
client.revoke_current_token()?;

if args.json {
return print_json(&LogoutOutput {
profile: None,
server: Some(client.base_url()),
revoked: true,
logged_out: false,
});
}

println!("Revoked the API token on {}", client.base_url());
println!("TEXTBIN_TOKEN is still set but now references a revoked token");
return Ok(());
Expand All @@ -45,16 +71,30 @@ pub fn handle(args: &LogoutArgs, settings: &Settings) -> Result<()> {
.unwrap_or(settings.active_profile_name()?);
settings.validate_profile_name(&profile_name)?;

if args.revoke {
let revoked_on = if args.revoke {
let client = settings
.stored_client(&profile_name)?
.context("profile is not authenticated; run `textbin auth login`")?;
client.revoke_current_token()?;
println!("Revoked the API token on {}", client.base_url());
}
if !args.json {
println!("Revoked the API token on {}", client.base_url());
}
Some(client.base_url().to_string())
} else {
None
};

let removed = settings.forget_login(&profile_name)?;

if args.json {
return print_json(&LogoutOutput {
profile: Some(&profile_name),
server: revoked_on.as_deref(),
revoked: revoked_on.is_some(),
logged_out: removed,
});
}

if removed {
println!("Logged out of profile {profile_name}");
} else {
Expand Down Expand Up @@ -100,6 +140,7 @@ mod tests {
&LogoutArgs {
revoke: true,
profile: Some("demo".to_string()),
json: false,
},
&settings,
);
Expand Down Expand Up @@ -145,6 +186,7 @@ mod tests {
&LogoutArgs {
revoke: false,
profile: Some("demo".to_string()),
json: false,
},
&settings,
);
Expand Down Expand Up @@ -178,6 +220,7 @@ mod tests {
&LogoutArgs {
revoke: true,
profile: None,
json: false,
},
&settings,
);
Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ pub enum Commands {
/// Log out of a Textbin server
Logout(logout::LogoutArgs),
/// Show the authenticated account
Whoami,
Whoami(whoami::WhoamiArgs),
}

pub fn handle(command: &Commands, settings: &Settings) -> anyhow::Result<()> {
match command {
Commands::Login(args) => login::handle(args, settings),
Commands::Logout(args) => logout::handle(args, settings),
Commands::Whoami => whoami::handle(settings),
Commands::Whoami(args) => whoami::handle(args, settings),
}
}
28 changes: 27 additions & 1 deletion crates/cli/src/auth/whoami.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
use anyhow::Result;
use clap::Args;
use serde::Serialize;
use textbin_client::{ApiTokenMetadata, AuthenticatedUser};

use crate::output::print_json;
use crate::settings::Settings;

pub fn handle(settings: &Settings) -> Result<()> {
#[derive(Args)]
pub struct WhoamiArgs {
/// Print the account, server and token as JSON
#[arg(long, default_value_t = false)]
json: bool,
}

#[derive(Serialize)]
struct WhoamiOutput<'a> {
server: &'a str,
user: &'a AuthenticatedUser,
token: &'a ApiTokenMetadata,
}

pub fn handle(args: &WhoamiArgs, settings: &Settings) -> Result<()> {
let client = settings.client()?;
let identity = client.identity()?;

if args.json {
return print_json(&WhoamiOutput {
server: client.base_url(),
user: &identity.user,
token: &identity.token,
});
}

println!("{}", identity.user.email);
println!("Server: {}", client.base_url());
println!("Token: {} ({})", identity.token.name, identity.token.id);
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/base/create.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::base::output::print_json;
use crate::output::print_json;
use crate::settings::Settings;
use clap::{Args, ValueEnum};
use std::fs;
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/base/delete.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::base::output::print_json;
use crate::output::print_json;
use crate::settings::Settings;
use clap::Args;
use serde::Serialize;
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/base/deleted.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::base::output::print_json;
use crate::output::print_json;
use crate::settings::Settings;
use clap::Args;
use textbin_client::RemovedPaste;
Expand Down
1 change: 0 additions & 1 deletion crates/cli/src/base/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
mod create;
mod delete;
mod deleted;
mod output;
mod restore;
mod show;

Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/base/restore.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::base::output::print_json;
use crate::output::print_json;
use crate::settings::Settings;
use clap::Args;

Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/base/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use lumis::themes;
use std::io::{self, IsTerminal, Write};
use textbin_client::Paste;

use crate::base::output::print_json;
use crate::output::print_json;
use crate::settings::Settings;

#[derive(Args)]
Expand Down
Loading
Loading