diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 797429b..f1141e5 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -7,6 +7,10 @@ This document contains the help content for the `screenly` command-line program. * [`screenly`↴](#screenly) * [`screenly login`↴](#screenly-login) * [`screenly logout`↴](#screenly-logout) +* [`screenly me`↴](#screenly-me) +* [`screenly auth`↴](#screenly-auth) +* [`screenly auth list`↴](#screenly-auth-list) +* [`screenly auth switch`↴](#screenly-auth-switch) * [`screenly screen`↴](#screenly-screen) * [`screenly screen list`↴](#screenly-screen-list) * [`screenly screen get`↴](#screenly-screen-get) @@ -58,6 +62,8 @@ Command line interface is intended for quick interaction with Screenly through t * `login` — Logs in with the provided token and stores it for further use if valid. You can set the API_TOKEN environment variable to override the stored token * `logout` — Logs out and removes the stored token +* `me` — Show information about the currently authenticated profile +* `auth` — Manage stored authentication profiles * `screen` — Screen related commands * `asset` — Asset related commands * `playlist` — Playlist related commands @@ -74,7 +80,11 @@ Command line interface is intended for quick interaction with Screenly through t Logs in with the provided token and stores it for further use if valid. You can set the API_TOKEN environment variable to override the stored token -**Usage:** `screenly login` +**Usage:** `screenly login [OPTIONS]` + +###### **Options:** + +* `--name ` — Profile name to store the token under. Required when other profiles already exist @@ -82,7 +92,56 @@ Logs in with the provided token and stores it for further use if valid. You can Logs out and removes the stored token -**Usage:** `screenly logout` +**Usage:** `screenly logout [OPTIONS]` + +###### **Options:** + +* `--name ` — Profile name to remove. Removes the active profile if not specified + + + +## `screenly me` + +Show information about the currently authenticated profile + +**Usage:** `screenly me [OPTIONS]` + +###### **Options:** + +* `-j`, `--json` — Enables JSON output + + + +## `screenly auth` + +Manage stored authentication profiles + +**Usage:** `screenly auth ` + +###### **Subcommands:** + +* `list` — List stored authentication profiles +* `switch` — Switch the active authentication profile + + + +## `screenly auth list` + +List stored authentication profiles + +**Usage:** `screenly auth list` + + + +## `screenly auth switch` + +Switch the active authentication profile + +**Usage:** `screenly auth switch [NAME]` + +###### **Arguments:** + +* `` — Profile name to activate diff --git a/src/authentication.rs b/src/authentication.rs index ae14e67..46c04ef 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -1,7 +1,10 @@ +use std::collections::HashMap; use std::{env, fs}; use reqwest::header::{HeaderMap, InvalidHeaderValue}; use reqwest::{header, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_yaml; use thiserror::Error; // For compatability reasons - let's leave build env as well. @@ -19,6 +22,8 @@ pub enum AuthenticationError { WrongCredentials, #[error("no credentials error")] NoCredentials, + #[error("profile not found: {0}")] + ProfileNotFound(String), #[error("request error")] Request(#[from] reqwest::Error), #[error("i/o error")] @@ -29,10 +34,18 @@ pub enum AuthenticationError { MissingHomeDir(), #[error("invalid header error")] InvalidHeader(#[from] InvalidHeaderValue), + #[error("yaml error")] + Yaml(#[from] serde_yaml::Error), #[error("unknown error")] Unknown, } +#[derive(Serialize, Deserialize, Default)] +struct TokenStore { + active: Option, + tokens: HashMap, +} + pub struct Authentication { pub config: Config, pub token: String, @@ -57,6 +70,35 @@ impl Config { } } +fn screenly_path() -> Result { + dirs::home_dir() + .map(|h| h.join(".screenly")) + .ok_or(AuthenticationError::MissingHomeDir()) +} + +fn read_store() -> Result { + let path = screenly_path()?; + if !path.exists() { + return Ok(TokenStore::default()); + } + let contents = fs::read_to_string(&path)?; + if let Ok(store) = serde_yaml::from_str::(&contents) { + return Ok(store); + } + // Backward compat: plain text token → migrate to "default" profile + let token = contents.trim().to_string(); + let mut store = TokenStore::default(); + store.tokens.insert("default".to_string(), token); + store.active = Some("default".to_string()); + Ok(store) +} + +fn write_store(store: &TokenStore) -> Result<(), AuthenticationError> { + let path = screenly_path()?; + fs::write(path, serde_yaml::to_string(store)?)?; + Ok(()) +} + impl Authentication { pub fn new() -> Result { Ok(Self { @@ -65,27 +107,59 @@ impl Authentication { }) } - pub fn remove_token() -> Result<(), AuthenticationError> { - match dirs::home_dir() { - Some(home) => { - fs::remove_file(home.join(".screenly"))?; - Ok(()) - } - None => Err(AuthenticationError::MissingHomeDir()), - } - } - fn read_token() -> Result { if let Ok(token) = env::var("API_TOKEN") { return Ok(token); } + let store = read_store()?; + let active = store.active.ok_or(AuthenticationError::NoCredentials)?; + store + .tokens + .get(&active) + .cloned() + .ok_or_else(|| AuthenticationError::ProfileNotFound(active)) + } - match dirs::home_dir() { - Some(path) => { - fs::read_to_string(path.join(".screenly")).map_err(AuthenticationError::Io) - } - None => Err(AuthenticationError::NoCredentials), + pub fn remove_token(name: Option<&str>) -> Result<(), AuthenticationError> { + let mut store = read_store()?; + let target = match name { + Some(n) => n.to_string(), + None => store + .active + .clone() + .ok_or(AuthenticationError::NoCredentials)?, + }; + if !store.tokens.contains_key(&target) { + return Err(AuthenticationError::ProfileNotFound(target)); } + store.tokens.remove(&target); + if store.active.as_deref() == Some(&target) { + store.active = store.tokens.keys().next().cloned(); + } + write_store(&store) + } + + pub fn list_profiles() -> Result, AuthenticationError> { + let store = read_store()?; + let mut profiles: Vec<(String, String, bool)> = store + .tokens + .iter() + .map(|(name, token)| { + let is_active = store.active.as_deref() == Some(name.as_str()); + (name.clone(), token.clone(), is_active) + }) + .collect(); + profiles.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(profiles) + } + + pub fn switch_profile(name: &str) -> Result<(), AuthenticationError> { + let mut store = read_store()?; + if !store.tokens.contains_key(name) { + return Err(AuthenticationError::ProfileNotFound(name.to_string())); + } + store.active = Some(name.to_string()); + write_store(&store) } #[cfg(test)] @@ -113,19 +187,63 @@ impl Authentication { } } +pub struct ProfileInfo { + pub email: String, + pub workspace: String, +} + +pub fn fetch_profile_info(token: &str, api_url: &str) -> Result { + let secret = format!("Token {token}"); + let client = reqwest::blocking::Client::builder().build()?; + + let user_response = client + .get(format!("{api_url}/v4.1/users/me")) + .header(header::AUTHORIZATION, &secret) + .send()?; + + if user_response.status() == StatusCode::UNAUTHORIZED { + return Err(AuthenticationError::WrongCredentials); + } + + let user: serde_json::Value = user_response.json()?; + + let email = user + .get(0) + .and_then(|u| u["email"].as_str()) + .unwrap_or("unknown") + .to_string(); + + let teams: serde_json::Value = client + .get(format!("{api_url}/v4.1/teams")) + .header(header::AUTHORIZATION, &secret) + .send()? + .json()?; + + let workspace = teams + .as_array() + .and_then(|arr| arr.iter().find(|t| t["is_current"].as_bool() == Some(true))) + .and_then(|t| t["name"].as_str()) + .unwrap_or("unknown") + .to_string(); + + Ok(ProfileInfo { email, workspace }) +} + +pub fn active_profile_name() -> Option { + read_store().ok().and_then(|s| s.active) +} + pub fn verify_and_store_token( token: &str, + name: &str, api_url: &str, ) -> anyhow::Result<(), AuthenticationError> { verify_token(token, api_url)?; - match dirs::home_dir() { - Some(home) => { - fs::write(home.join(".screenly"), token)?; - Ok(()) - } - None => Err(AuthenticationError::MissingHomeDir()), - } + let mut store = read_store()?; + store.tokens.insert(name.to_string(), token.to_string()); + store.active = Some(name.to_string()); + write_store(&store) } fn verify_token(token: &str, api_url: &str) -> anyhow::Result<(), AuthenticationError> { @@ -180,11 +298,14 @@ mod tests { let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config, ""); - assert!(verify_and_store_token("correct_token", &authentication.config.url).is_ok()); + assert!( + verify_and_store_token("correct_token", "default", &authentication.config.url).is_ok() + ); let path = tmp_dir.path().join(".screenly"); assert!(path.exists()); - let contents = fs::read_to_string(path).unwrap(); - assert!(contents.eq("correct_token")); + let store: TokenStore = serde_yaml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!(store.tokens.get("default").unwrap(), "correct_token"); + assert_eq!(store.active.unwrap(), "default"); } #[test] @@ -202,7 +323,7 @@ mod tests { }); let config = Config::new(mock_server.base_url()); - assert!(verify_and_store_token("wrong_token", &config.url).is_err()); + assert!(verify_and_store_token("wrong_token", "default", &config.url).is_err()); let path = tmp_dir.path().join(".screenly"); assert!(!path.exists()); @@ -214,8 +335,17 @@ mod tests { let _lock = lock_test(); let _token = set_env(OsString::from("API_TOKEN"), "env_token"); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); - println!("{}", tmp_dir.path().join(".screenly").to_str().unwrap()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); assert_eq!(Authentication::read_token().unwrap(), "env_token"); } @@ -224,20 +354,127 @@ mod tests { let tmp_dir = tempdir().unwrap(); let _lock = lock_test(); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); - + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); assert_eq!(Authentication::read_token().unwrap(), "token"); } #[test] - fn test_remove_token_should_remove_token_from_storage() { + fn test_read_token_backward_compat_plain_text() { let tmp_dir = tempdir().unwrap(); let _lock = lock_test(); let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").unwrap(); + fs::write(tmp_dir.path().join(".screenly"), "legacy_token").unwrap(); + assert_eq!(Authentication::read_token().unwrap(), "legacy_token"); + } + + #[test] + fn test_remove_token_should_remove_active_profile() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("default".to_string()), + tokens: [("default".to_string(), "token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + Authentication::remove_token(None).unwrap(); + let store: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert!(store.tokens.is_empty()); + assert!(store.active.is_none()); + } + + #[test] + fn test_switch_profile_should_change_active() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + Authentication::switch_profile("stage").unwrap(); + let updated: TokenStore = + serde_yaml::from_str(&fs::read_to_string(tmp_dir.path().join(".screenly")).unwrap()) + .unwrap(); + assert_eq!(updated.active.unwrap(), "stage"); + } - Authentication::remove_token().unwrap(); - assert!(!tmp_dir.path().join(".screenly").exists()); + #[test] + fn test_switch_profile_to_nonexistent_should_fail() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [("prod".to_string(), "prod_token".to_string())] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + assert!(Authentication::switch_profile("ghost").is_err()); + } + + #[test] + fn test_list_profiles_should_return_profiles_with_active_marked() { + let tmp_dir = tempdir().unwrap(); + let _lock = lock_test(); + let _test = set_env(OsString::from("HOME"), tmp_dir.path().to_str().unwrap()); + let store = TokenStore { + active: Some("prod".to_string()), + tokens: [ + ("prod".to_string(), "prod_token".to_string()), + ("stage".to_string(), "stage_token".to_string()), + ] + .into_iter() + .collect(), + }; + fs::write( + tmp_dir.path().join(".screenly"), + serde_yaml::to_string(&store).unwrap(), + ) + .unwrap(); + + let profiles = Authentication::list_profiles().unwrap(); + assert_eq!(profiles.len(), 2); + let prod = profiles.iter().find(|(n, _, _)| n == "prod").unwrap(); + let stage = profiles.iter().find(|(n, _, _)| n == "stage").unwrap(); + assert!(prod.2); + assert!(!stage.2); } #[test] @@ -257,11 +494,50 @@ mod tests { let config = Config::new(mock_server.base_url()); let authentication = Authentication::new_with_config(config, ""); - assert!(verify_and_store_token("correct_token", &authentication.config.url).is_ok()); + assert!( + verify_and_store_token("correct_token", "default", &authentication.config.url).is_ok() + ); let path = tmp_dir.path().join(".screenly"); assert!(path.exists()); - let contents = fs::read_to_string(path).unwrap(); + let store: TokenStore = serde_yaml::from_str(&fs::read_to_string(path).unwrap()).unwrap(); group_call_mock.assert(); - assert!(contents.eq("correct_token")); + assert_eq!(store.tokens.get("default").unwrap(), "correct_token"); + } + + #[test] + fn test_fetch_profile_info_returns_email_and_workspace() { + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4.1/users/me") + .header("Authorization", "Token valid_token"); + then.status(200) + .json_body(serde_json::json!([{"email": "user@example.com"}])); + }); + mock_server.mock(|when, then| { + when.method(GET) + .path("/v4.1/teams") + .header("Authorization", "Token valid_token"); + then.status(200) + .json_body(serde_json::json!([{"name": "My Team", "is_current": true}])); + }); + + let result = fetch_profile_info("valid_token", &mock_server.base_url()); + assert!(result.is_ok()); + let info = result.unwrap(); + assert_eq!(info.email, "user@example.com"); + assert_eq!(info.workspace, "My Team"); + } + + #[test] + fn test_fetch_profile_info_returns_wrong_credentials_on_401() { + let mock_server = MockServer::start(); + mock_server.mock(|when, then| { + when.method(GET).path("/v4.1/users/me"); + then.status(401); + }); + + let result = fetch_profile_info("bad_token", &mock_server.base_url()); + assert!(matches!(result, Err(AuthenticationError::WrongCredentials))); } } diff --git a/src/cli.rs b/src/cli.rs index d3ba88e..6b6f1a1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,7 +9,10 @@ use reqwest::StatusCode; use rpassword::read_password; use thiserror::Error; -use crate::authentication::{verify_and_store_token, Authentication, AuthenticationError, Config}; +use crate::authentication::{ + active_profile_name, fetch_profile_info, verify_and_store_token, Authentication, + AuthenticationError, Config, +}; use crate::commands; use crate::commands::edge_app::instance_manifest::InstanceManifest; use crate::commands::edge_app::manifest::EdgeAppManifest; @@ -28,6 +31,9 @@ fn get_authentication_error_message(e: &AuthenticationError) -> String { AuthenticationError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => { "Not logged in. Please run `screenly login` first to authenticate.".to_string() } + AuthenticationError::ProfileNotFound(name) => { + format!("Active profile '{name}' not found. Run `screenly auth switch` to pick a valid profile.") + } _ => { format!("Authentication error: {e}. Please run `screenly login` to authenticate.") } @@ -75,9 +81,26 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { /// Logs in with the provided token and stores it for further use if valid. You can set the API_TOKEN environment variable to override the stored token. - Login {}, + Login { + /// Profile name to store the token under. Required when other profiles already exist. + #[arg(long)] + name: Option, + }, /// Logs out and removes the stored token. - Logout {}, + Logout { + /// Profile name to remove. Removes the active profile if not specified. + #[arg(long)] + name: Option, + }, + /// Show information about the currently authenticated profile. + Me { + /// Enables JSON output. + #[arg(short, long, action = clap::ArgAction::SetTrue)] + json: Option, + }, + /// Manage stored authentication profiles. + #[command(subcommand)] + Auth(AuthCommands), /// Screen related commands. #[command(subcommand)] Screen(ScreenCommands), @@ -97,6 +120,17 @@ pub enum Commands { PrintHelpMarkdown {}, } +#[derive(Subcommand)] +pub enum AuthCommands { + /// List stored authentication profiles. + List {}, + /// Switch the active authentication profile. + Switch { + /// Profile name to activate. + name: Option, + }, +} + #[derive(Subcommand, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ScreenCommands { /// Lists your screens. @@ -563,13 +597,27 @@ pub fn get_asset_title( pub fn handle_cli(cli: &Cli) { match &cli.command { - Commands::Login {} => { + Commands::Login { name } => { + let resolved_name = match name { + Some(n) => n.clone(), + None => { + let existing = Authentication::list_profiles().unwrap_or_default(); + if existing.is_empty() { + "default".to_string() + } else { + error!( + "Multiple profiles exist. Please specify a profile name with --name." + ); + std::process::exit(1); + } + } + }; print!("Enter your API Token: "); std::io::stdout().flush().unwrap(); let token = read_password().unwrap(); - match verify_and_store_token(&token, &Config::default().url) { + match verify_and_store_token(&token, &resolved_name, &Config::default().url) { Ok(()) => { - info!("Login credentials have been saved."); + info!("Login credentials have been saved under profile '{resolved_name}'."); std::process::exit(0); } @@ -589,11 +637,136 @@ pub fn handle_cli(cli: &Cli) { Commands::Asset(command) => handle_cli_asset_command(command), Commands::EdgeApp(command) => handle_cli_edge_app_command(command), Commands::Playlist(command) => handle_cli_playlist_command(command), - Commands::Logout {} => { - Authentication::remove_token().expect("Failed to remove token."); + Commands::Me { json } => { + let auth = get_authentication(); + match fetch_profile_info(&auth.token, &auth.config.url) { + Ok(info) => { + let json_flag = json.unwrap_or(false); + if json_flag { + let mut obj = serde_json::Map::new(); + if let Some(name) = active_profile_name() { + obj.insert("profile".to_string(), serde_json::Value::String(name)); + } + obj.insert("email".to_string(), serde_json::Value::String(info.email)); + obj.insert( + "workspace".to_string(), + serde_json::Value::String(info.workspace), + ); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::Value::Object(obj)).unwrap() + ); + } else { + if let Some(name) = active_profile_name() { + println!("Profile: {name}"); + } + println!("Email: {}", info.email); + println!("Workspace: {}", info.workspace); + } + } + Err(AuthenticationError::WrongCredentials) => { + error!("Token is invalid. Run `screenly login` to update your credentials."); + std::process::exit(1); + } + Err(e) => { + error!("Failed to fetch profile info: {e}"); + std::process::exit(1); + } + } + } + Commands::Logout { name } => { + Authentication::remove_token(name.as_deref()).expect("Failed to remove token."); info!("Logout successful."); std::process::exit(0); } + Commands::Auth(auth_command) => match auth_command { + AuthCommands::List {} => match Authentication::list_profiles() { + Ok(profiles) if profiles.is_empty() => { + info!("No profiles stored. Run `screenly login` to add one."); + } + Ok(profiles) => { + let api_url = Config::default().url; + let rows: Vec<(String, bool, Option<(String, String)>)> = profiles + .into_iter() + .map(|(name, token, is_active)| { + let info = fetch_profile_info(&token, &api_url) + .ok() + .map(|i| (i.email, i.workspace)); + (name, is_active, info) + }) + .collect(); + + let name_w = rows.iter().map(|(n, _, _)| n.len()).max().unwrap_or(0); + let email_w = rows + .iter() + .filter_map(|(_, _, i)| i.as_ref().map(|(e, _)| e.len())) + .max() + .unwrap_or(0); + + println!(" {: { + println!("{marker} {name: println!("{marker} {name}"), + } + } + } + Err(e) => { + error!("Error occurred: {e:?}"); + std::process::exit(1); + } + }, + AuthCommands::Switch { name } => match name { + None => { + let api_url = Config::default().url; + if let Ok(profiles) = Authentication::list_profiles() { + let rows: Vec<(String, bool, Option<(String, String)>)> = profiles + .into_iter() + .map(|(name, token, is_active)| { + let info = fetch_profile_info(&token, &api_url) + .ok() + .map(|i| (i.email, i.workspace)); + (name, is_active, info) + }) + .collect(); + let name_w = rows.iter().map(|(n, _, _)| n.len()).max().unwrap_or(0); + let email_w = rows + .iter() + .filter_map(|(_, _, i)| i.as_ref().map(|(e, _)| e.len())) + .max() + .unwrap_or(0); + println!(" {: println!( + "{marker} {name: println!("{marker} {name}"), + } + } + } + } + Some(name) => match Authentication::switch_profile(name) { + Ok(()) => { + info!("Switched to profile '{name}'."); + } + Err(AuthenticationError::ProfileNotFound(_)) => { + error!("Profile '{name}' not found."); + std::process::exit(1); + } + Err(e) => { + error!("Error occurred: {e:?}"); + std::process::exit(1); + } + }, + }, + }, Commands::Mcp {} => { handle_cli_mcp_command(); }