From 24c32c4f042262e19d685e9606b7d208b4d54210 Mon Sep 17 00:00:00 2001 From: 514sid Date: Sat, 6 Jun 2026 12:34:14 +0400 Subject: [PATCH 1/4] Add multi-profile auth: login --name, logout --name, auth list, auth switch --- docs/CommandLineHelp.md | 49 ++++++- src/authentication.rs | 304 +++++++++++++++++++++++++++++++++++----- src/cli.rs | 142 +++++++++++++++++-- 3 files changed, 448 insertions(+), 47 deletions(-) diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 797429b..50fceae 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -7,6 +7,9 @@ This document contains the help content for the `screenly` command-line program. * [`screenly`↴](#screenly) * [`screenly login`↴](#screenly-login) * [`screenly logout`↴](#screenly-logout) +* [`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 +61,7 @@ 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 +* `auth` — Manage stored authentication profiles * `screen` — Screen related commands * `asset` — Asset related commands * `playlist` — Playlist related commands @@ -74,7 +78,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 +90,44 @@ 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 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..c623887 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(AuthenticationError::NoCredentials) + } - 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,54 @@ 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: serde_json::Value = client + .get(format!("{api_url}/v4.1/users/me")) + .header(header::AUTHORIZATION, &secret) + .send()? + .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 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 +289,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 +314,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 +326,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 +345,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"), "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()); - fs::write(tmp_dir.path().join(".screenly").to_str().unwrap(), "token").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 +485,13 @@ 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"); } } diff --git a/src/cli.rs b/src/cli.rs index d3ba88e..0c31687 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,7 +9,9 @@ use reqwest::StatusCode; use rpassword::read_password; use thiserror::Error; -use crate::authentication::{verify_and_store_token, Authentication, AuthenticationError, Config}; +use crate::authentication::{ + 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; @@ -75,9 +77,20 @@ 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, + }, + /// Manage stored authentication profiles. + #[command(subcommand)] + Auth(AuthCommands), /// Screen related commands. #[command(subcommand)] Screen(ScreenCommands), @@ -97,6 +110,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 +587,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 +627,99 @@ 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::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(); } From 7062276d5be030abf63ce62741e46a5ce78eb6f6 Mon Sep 17 00:00:00 2001 From: 514sid Date: Sat, 6 Jun 2026 12:57:31 +0400 Subject: [PATCH 2/4] Return ProfileNotFound when active profile missing, show actionable error message --- src/authentication.rs | 2 +- src/cli.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/authentication.rs b/src/authentication.rs index c623887..0b08896 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -117,7 +117,7 @@ impl Authentication { .tokens .get(&active) .cloned() - .ok_or(AuthenticationError::NoCredentials) + .ok_or_else(|| AuthenticationError::ProfileNotFound(active)) } pub fn remove_token(name: Option<&str>) -> Result<(), AuthenticationError> { diff --git a/src/cli.rs b/src/cli.rs index 0c31687..a8defad 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,6 +30,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.") } From ca9d932407b0277c09c21ac66927477d44d4faa5 Mon Sep 17 00:00:00 2001 From: 514sid Date: Sat, 6 Jun 2026 13:39:21 +0400 Subject: [PATCH 3/4] Feat: add 'me' command to show current profile info Displays the email and workspace for the active token, with proper error messages for unauthenticated and invalid-token cases. --- docs/CommandLineHelp.md | 14 +++++++++++ src/authentication.rs | 53 ++++++++++++++++++++++++++++++++++++--- src/cli.rs | 55 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/CommandLineHelp.md b/docs/CommandLineHelp.md index 50fceae..f1141e5 100644 --- a/docs/CommandLineHelp.md +++ b/docs/CommandLineHelp.md @@ -7,6 +7,7 @@ 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) @@ -61,6 +62,7 @@ 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 @@ -98,6 +100,18 @@ Logs out and removes the stored token +## `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 diff --git a/src/authentication.rs b/src/authentication.rs index 0b08896..9cd45fc 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -196,11 +196,16 @@ pub fn fetch_profile_info(token: &str, api_url: &str) -> Result Result Option { + read_store().ok().and_then(|s| s.active) +} + pub fn verify_and_store_token( token: &str, name: &str, @@ -494,4 +503,42 @@ mod tests { group_call_mock.assert(); 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 a8defad..dcbbc74 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -10,7 +10,8 @@ use rpassword::read_password; use thiserror::Error; use crate::authentication::{ - fetch_profile_info, verify_and_store_token, Authentication, AuthenticationError, Config, + active_profile_name, fetch_profile_info, verify_and_store_token, Authentication, + AuthenticationError, Config, }; use crate::commands; use crate::commands::edge_app::instance_manifest::InstanceManifest; @@ -91,6 +92,12 @@ pub enum Commands { #[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), @@ -630,6 +637,52 @@ 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::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."); From ffdf3e35b880bfde8fdb6527d6303d2f10bdc620 Mon Sep 17 00:00:00 2001 From: 514sid Date: Sat, 6 Jun 2026 16:26:51 +0400 Subject: [PATCH 4/4] Fix: apply rustfmt formatting --- src/authentication.rs | 5 ++--- src/cli.rs | 17 ++++------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/authentication.rs b/src/authentication.rs index 9cd45fc..46c04ef 100644 --- a/src/authentication.rs +++ b/src/authentication.rs @@ -518,9 +518,8 @@ mod tests { 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}]), - ); + 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()); diff --git a/src/cli.rs b/src/cli.rs index dcbbc74..6b6f1a1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -645,23 +645,16 @@ pub fn handle_cli(cli: &Cli) { 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("profile".to_string(), serde_json::Value::String(name)); } - obj.insert( - "email".to_string(), - serde_json::Value::String(info.email), - ); + 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() + serde_json::to_string_pretty(&serde_json::Value::Object(obj)).unwrap() ); } else { if let Some(name) = active_profile_name() { @@ -672,9 +665,7 @@ pub fn handle_cli(cli: &Cli) { } } Err(AuthenticationError::WrongCredentials) => { - error!( - "Token is invalid. Run `screenly login` to update your credentials." - ); + error!("Token is invalid. Run `screenly login` to update your credentials."); std::process::exit(1); } Err(e) => {