From 18121fba36d434e32b82ef5ff7f12da537f65b61 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Thu, 22 Jun 2023 23:43:33 -0500 Subject: [PATCH] Identity command improvements (#11) * Working on improving commands that use identities * Fix lints * Reverted file that shouldn't have changed * Found and fixed all other todos * Addressed more CLI TODOs * Fixes for formatting issues * Set names of identities * Set name of identities + clippy * Small fix * Added the start of a doc comment, switching over to another PR * Fixed tests that needed to be updated * Addressed more feedback and fixed several clippy issues * Small fix * Apply suggestions from code review Co-authored-by: Mazdak Farrokhzad Signed-off-by: John Detter <4099508+jdetter@users.noreply.github.com> * Added more doc comments * Addressing more feedback * Fixed really old bug in SpacetimeDB * Tests to verify new functionality * Fix clippy lints * Email during identity creation is optional * Fix output so testsuite passes --------- Signed-off-by: John Detter <4099508+jdetter@users.noreply.github.com> Co-authored-by: Boppy Co-authored-by: Mazdak Farrokhzad --- crates/cli/src/config.rs | 77 ++++++ crates/cli/src/subcommands/delete.rs | 4 +- crates/cli/src/subcommands/identity.rs | 251 ++++++++++-------- crates/cli/src/subcommands/logs.rs | 7 +- crates/cli/src/subcommands/publish.rs | 6 +- crates/cli/src/util.rs | 44 ++- crates/client-api/src/routes/identity.rs | 16 +- test/tests/identity-new-email.sh | 36 +++ test/tests/identity-remove.sh | 4 +- test/tests/identity-set-default.sh | 2 +- ...dentity-tests.sh => identity-set-email.sh} | 8 +- test/tests/permissions-call.sh | 2 +- test/tests/permissions-logs.sh | 2 +- test/tests/permissions-publish.sh | 2 +- 14 files changed, 320 insertions(+), 141 deletions(-) create mode 100644 test/tests/identity-new-email.sh rename test/tests/{identity-tests.sh => identity-set-email.sh} (80%) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 4b76cc9eeef..904381b2de0 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,3 +1,4 @@ +use crate::util::is_hex_identity; use serde::{Deserialize, Serialize}; use std::{ fs, @@ -21,6 +22,7 @@ pub struct RawConfig { identity_configs: Option>, } +#[derive(Clone)] pub struct Config { proj: RawConfig, home: RawConfig, @@ -83,6 +85,31 @@ impl Config { self.home.default_identity = Some(default_identity); } + /// Sets the `nickname` for the provided `identity`. + /// + /// If the `identity` already has a `nickname` set, it will be overwritten and returned. If the + /// `identity` is not found, an error will be returned. + /// + /// # Returns + /// * `Ok(Option)` - If the identity was found, the old nickname will be returned. + /// * `Err(anyhow::Error)` - If the identity was not found. + pub fn set_identity_nickname(&mut self, identity: &str, nickname: &str) -> Result, anyhow::Error> { + match &mut self.home.identity_configs { + None => { + panic!("Identity {} not found", identity); + } + Some(ref mut configs) => { + let config = configs + .iter_mut() + .find(|c| c.identity == identity) + .ok_or_else(|| anyhow::anyhow!("Identity {} not found", identity))?; + let old_nickname = config.nickname.clone(); + config.nickname = Some(nickname.to_string()); + Ok(old_nickname) + } + } + } + pub fn default_address(&self) -> Option<&str> { self.proj .default_identity @@ -224,6 +251,49 @@ impl Config { self.identity_configs_mut().iter_mut().find(|c| c.identity == identity) } + /// Converts some given `identity_or_name` into an identity. + /// + /// If `identity_or_name` is `None` then `None` is returned. If `identity_or_name` is `Some`, + /// then if its an identity then its just returned. If its not an identity it is assumed to be + /// a name and it is looked up as an identity nickname. If the identity exists it is returned, + /// otherwise we panic. + pub fn resolve_name_to_identity(&self, identity_or_name: Option<&str>) -> Option { + identity_or_name + .map(|identity_or_name| { + if is_hex_identity(identity_or_name) { + &self + .identity_configs() + .iter() + .find(|c| c.identity == *identity_or_name) + .unwrap_or_else(|| panic!("No such identity: {}", identity_or_name)) + .identity + } else { + &self + .identity_configs() + .iter() + .find(|c| c.nickname == Some(identity_or_name.to_string())) + .unwrap_or_else(|| panic!("No such identity: {}", identity_or_name)) + .identity + } + }) + .cloned() + } + + /// Converts some given `identity_or_name` into a mutable `IdentityConfig`. + /// + /// # Returns + /// * `None` - If an identity config with the given `identity_or_name` does not exist. + /// * `Some` - A mutable reference to the `IdentityConfig` with the given `identity_or_name`. + pub fn get_identity_config_mut(&mut self, identity_or_name: &str) -> Option<&mut IdentityConfig> { + if is_hex_identity(identity_or_name) { + self.get_identity_config_by_identity_mut(identity_or_name) + } else { + self.identity_configs_mut() + .iter_mut() + .find(|c| c.nickname.as_deref() == Some(identity_or_name)) + } + } + pub fn delete_identity_config_by_name(&mut self, name: &str) -> Option { let index = self .home @@ -254,6 +324,13 @@ impl Config { } } + /// Deletes all stored identity configs. This function does not save the config after removing + /// all configs. + pub fn delete_all_identity_configs(&mut self) { + self.home.identity_configs = Some(vec![]); + self.home.default_identity = None; + } + pub fn update_default_identity(&mut self) { if let Some(default_identity) = &self.home.default_identity { if self diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index 9f460f834f5..d6a15c74214 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -27,8 +27,8 @@ pub fn cli() -> clap::Command { pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let database = args.get_one::("database").unwrap(); - let identity = args.get_one::("identity"); - let auth_header = get_auth_header(&mut config, false, identity.map(|x| x.as_str())) + let identity_or_name = args.get_one::("identity"); + let auth_header = get_auth_header(&mut config, false, identity_or_name.map(|x| x.as_str())) .await .map(|x| x.0); diff --git a/crates/cli/src/subcommands/identity.rs b/crates/cli/src/subcommands/identity.rs index 0c57606676c..1ffacb557f0 100644 --- a/crates/cli/src/subcommands/identity.rs +++ b/crates/cli/src/subcommands/identity.rs @@ -4,7 +4,7 @@ use crate::{ }; use std::io::Write; -use anyhow::Context; +use crate::util::{is_hex_identity, print_identity_config}; use clap::{Arg, ArgAction, ArgMatches, Command}; use email_address::EmailAddress; use reqwest::{StatusCode, Url}; @@ -20,41 +20,20 @@ pub fn cli() -> Command { .about("Manage identities stored by the command line tool") } -// TODO(jdetter): identity name and the identity itself should be ubiquitous. You should be able to pass -// an identity or the alias into the command instead of this --name/--identity business fn get_subcommands() -> Vec { vec![ Command::new("list").about("List saved identities"), - Command::new("set-default") - // TODO(jdetter): Unify providing an identity an a name - .about("Set the default identity") - .arg( - Arg::new("identity") - .long("identity") - .short('i') - .help("The identity that should become the new default identity") - .conflicts_with("name"), - ) - .arg( - Arg::new("name") - .long("name") - .short('n') - .help("The name of the identity that should become the new default identity") - .conflicts_with("identity"), - ), + Command::new("set-default").about("Set the default identity").arg( + Arg::new("identity") + .help("The identity string or name that should become the new default identity") + .required(true), + ), Command::new("set-email") .about("Associates an email address with an identity") .arg( Arg::new("identity") - .long("identity") - .short('i') - .help("The identity that should become the new default identity"), - ) - .arg( - Arg::new("name") - .long("name") - .short('n') - .help("The name of the identity that should become the new default identity"), + .help("The identity string or name that should become the new default identity") + .required(true), ) .arg( Arg::new("email") @@ -107,27 +86,37 @@ fn get_subcommands() -> Vec { ), Command::new("remove") .about("Removes a saved identity from your spacetime config") - // TODO(jdetter): Unify identity + name parameters - .arg( - Arg::new("identity") - .long("identity") - .short('i') - .help("The identity to delete") - .conflicts_with("name"), + .arg(Arg::new("identity") + .help("The identity string or name to delete") + .required_unless_present("all") ) .arg( - Arg::new("name") - .long("name") - .short('n') - .help("The name of the identity to delete") - .conflicts_with("identity"), + Arg::new("all") + .long("all") + .help("Remove all identities from your spacetime config") + .action(ArgAction::SetTrue) + .conflicts_with("identity") + .required_unless_present("identity"), ), + Command::new("token").about("Print the token for an identity").arg( + Arg::new("identity") + .help("The identity string or name that we should print the token for") + .required(true), + ), + Command::new("set-name").about("Set the name of an identity or rename an existing identity nickname").arg( + Arg::new("identity") + .help("The identity string or name to be named. If a name is supplied, the corresponding identity will be renamed.") + .required(true)) + .arg(Arg::new("name") + .help("The new name for the identity") + .required(true) + ), Command::new("import") - .about("Imports an existing identity into your spacetime config") + .about("Import an existing identity into your spacetime config") .arg( Arg::new("identity") .required(true) - .help("The identity that is associated with the provided token"), + .help("The identity string associated with the provided token"), ) .arg( Arg::new("token") @@ -152,7 +141,6 @@ fn get_subcommands() -> Vec { .required(true) .help("The email associated with the identity that you would like to recover."), ) - // TODO(jdetter): Unify identity and name here .arg(Arg::new("identity").required(true).help( "The identity you would like to recover. This identity must be associated with the email provided.", )), @@ -171,46 +159,30 @@ async fn exec_subcommand(config: Config, cmd: &str, args: &ArgMatches) -> Result "init-default" => exec_init_default(config, args).await, "new" => exec_new(config, args).await, "remove" => exec_remove(config, args).await, - // TODO(jdetter): Rename to import + "set-name" => exec_set_name(config, args).await, "import" => exec_import(config, args).await, "set-email" => exec_set_email(config, args).await, "find" => exec_find(config, args).await, + "token" => exec_token(config, args).await, "recover" => exec_recover(config, args).await, - // TODO(jdetter): Command for logging in via email recovery unknown => Err(anyhow::anyhow!("Invalid subcommand: {}", unknown)), } } +/// Executes the `identity set-default` command which sets the default identity. async fn exec_set_default(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let name = args.get_one::("name"); - if let Some(name) = name { - if let Some(identity_config) = config.get_identity_config_by_name(name) { - config.set_default_identity(identity_config.identity.clone()); - config.save(); - return Ok(()); - } else { - return Err(anyhow::anyhow!("No such identity by that name.")); - } - } - - if let Some(identity) = args.get_one::("identity") { - if let Some(identity_config) = config.get_identity_config_by_identity(identity) { - config.set_default_identity(identity_config.identity.clone()); - config.save(); - return Ok(()); - } else { - return Err(anyhow::anyhow!("No such identity.")); - } - } - - Err(anyhow::anyhow!( - "Either an identity or the name of an identity must be provided." - )) + let identity = config + .resolve_name_to_identity(args.get_one::("identity").map(|s| s.as_ref())) + .unwrap(); + config.set_default_identity(identity); + config.save(); + Ok(()) } // TODO(cloutiertyler): Realistically this should just be run before every // single command, but I'm separating it out into its own command for now for // simplicity. +/// Executes the `identity init-default` command which initializes the default identity. async fn exec_init_default(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let nickname = args.get_one::("name").map(|s| s.to_owned()); let quiet = args.get_flag("quiet"); @@ -223,16 +195,12 @@ async fn exec_init_default(mut config: Config, args: &ArgMatches) -> Result<(), match result_type { InitDefaultResultType::Existing => { println!(" Existing default identity"); - // TODO(jdetter): This should be standardized output - println!(" IDENTITY {}", identity_config.identity); - println!(" NAME {}", identity_config.nickname.unwrap_or_default()); + print_identity_config(&identity_config); return Ok(()); } InitDefaultResultType::SavedNew => { println!(" Saved new identity"); - // TODO(jdetter): This should be standardized output - println!(" IDENTITY {}", identity_config.identity); - println!(" NAME {}", identity_config.nickname.unwrap_or_default()); + print_identity_config(&identity_config); } } } @@ -240,39 +208,49 @@ async fn exec_init_default(mut config: Config, args: &ArgMatches) -> Result<(), Ok(()) } +/// Executes the `identity remove` command which removes an identity from the config. async fn exec_remove(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let name = args.get_one::("name"); - if let Some(name) = name { - let ic = config.delete_identity_config_by_name(name); - if let Some(ic) = ic { - config.update_default_identity(); - config.save(); - println!(" Removed identity"); - // TODO(jdetter): Standardize this identity output - println!(" IDENTITY {}", ic.identity); - println!(" NAME {}", ic.nickname.unwrap_or_default()); + let identity_or_name = args.get_one::("identity"); + + if let Some(identity_or_name) = identity_or_name { + let ic = if is_hex_identity(identity_or_name) { + config.delete_identity_config_by_identity(identity_or_name.as_str()) + } else { + config.delete_identity_config_by_name(identity_or_name.as_str()) + } + .unwrap_or_else(|| panic!("No such identity or name: {}", identity_or_name)); + config.update_default_identity(); + config.save(); + println!(" Removed identity"); + print_identity_config(&ic); + } else { + if config.identity_configs().is_empty() { + println!(" No identities to remove"); return Ok(()); + } + + print!("Are you sure you want to remove all identities? (y/n) "); + std::io::stdout().flush()?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if input.trim() == "y" { + let identity_count = config.identity_configs().len(); + config.delete_all_identity_configs(); + config.save(); + println!( + " {} {} removed.", + identity_count, + if identity_count > 1 { "identities" } else { "identity" } + ); } else { - println!("No such identity by that name."); - return Err(anyhow::anyhow!("No such identity.")); + println!(" Aborted"); } } - - let identity = args - .get_one::("identity") - .context("You either need to supply a name or identity to delete.")?; - let ic = config - .delete_identity_config_by_identity(identity) - .context("No such identity")?; - config.update_default_identity(); - config.save(); - println!(" Removed identity"); - // TODO(jdetter): This should be standardized output - println!(" IDENTITY {}", ic.identity); - println!(" NAME {}", ic.nickname.unwrap_or_default()); Ok(()) } +/// Executes the `identity new` command which creates a new identity. async fn exec_new(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let save = !args.get_flag("no-save"); let alias = args.get_one::("name"); @@ -280,6 +258,10 @@ async fn exec_new(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E if config.name_exists(alias) { return Err(anyhow::anyhow!("An identity with that name already exists.")); } + + if is_hex_identity(alias.as_str()) { + return Err(anyhow::anyhow!("An identity name cannot be an identity.")); + } } let email = args.get_one::("email"); @@ -337,6 +319,7 @@ async fn exec_new(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E Ok(()) } +/// Executes the `identity import` command which imports an identity from a token into the config. async fn exec_import(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let identity: String = args.get_one::("identity").unwrap().clone(); let token: String = args.get_one::("token").unwrap().clone(); @@ -369,6 +352,7 @@ struct LsRow { // email: String, } +/// Executes the `identity list` command which lists all identities in the config. async fn exec_list(config: Config, _args: &ArgMatches) -> Result<(), anyhow::Error> { let mut rows: Vec = Vec::new(); for identity_token in config.identity_configs() { @@ -405,6 +389,7 @@ struct GetIdentityResponseEntry { email: String, } +/// Executes the `identity find` command which finds an identity by email. async fn exec_find(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let email = args.get_one::("email").unwrap().clone(); let client = reqwest::Client::new(); @@ -432,19 +417,61 @@ async fn exec_find(config: Config, args: &ArgMatches) -> Result<(), anyhow::Erro } } +/// Executes the `identity token` command which prints the token for an identity. +async fn exec_token(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let identity_or_name = config + .resolve_name_to_identity(args.get_one::("identity").map(|s| s.as_str())) + .unwrap(); + let ic = config + .get_identity_config_by_identity(identity_or_name.as_str()) + .unwrap(); + println!("{}", ic.token); + Ok(()) +} + +/// Executes the `identity set-default` command which sets the default identity. +async fn exec_set_name(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let cloned_config = config.clone(); + let identity_or_name = cloned_config + .resolve_name_to_identity(args.get_one::("identity").map(|s| s.as_ref())) + .unwrap(); + let new_name = args.get_one::("name").unwrap().as_ref(); + let old_nickname = config.set_identity_nickname(identity_or_name.as_ref(), new_name)?; + if let Some(old_nickname) = old_nickname { + println!("Updated identity: {}", identity_or_name); + println!(" OLD NAME: {}", old_nickname); + println!(" NEW NAME: {}", new_name); + } else { + println!("Created identity: {}", identity_or_name); + println!(" NAME: {}", new_name); + } + config.save(); + let ic = config + .get_identity_config_by_identity(identity_or_name.as_ref()) + .unwrap(); + print_identity_config(ic); + Ok(()) +} + +/// Executes the `identity set-email` command which sets the email for an identity. async fn exec_set_email(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let email = args.get_one::("email").unwrap().clone(); - let identity = args.get_one::("identity").unwrap().clone(); + let identity = config + .resolve_name_to_identity(args.get_one::("identity").map(|s| s.as_ref())) + .unwrap(); + let identity_config = config + .get_identity_config_by_identity(identity.as_str()) + .unwrap_or_else(|| panic!("Could not find identity: {}", identity)); let client = reqwest::Client::new(); let mut builder = client.post(format!( "{}/identity/{}/set-email?email={}", config.get_host_url(), - identity, + identity_config.identity, email )); - if let Some(identity_token) = config.get_identity_config_by_identity(&identity) { + if let Some(identity_token) = config.get_identity_config_by_identity(identity.as_str()) { builder = builder.basic_auth("token", Some(identity_token.token.clone())); } else { println!("Missing identity credentials for identity."); @@ -455,16 +482,18 @@ async fn exec_set_email(config: Config, args: &ArgMatches) -> Result<(), anyhow: res.error_for_status()?; println!(" Associated email with identity"); - // TODO(jdetter): standardize this output - println!(" IDENTITY {}", identity); - println!(" EMAIL {}", email); + print_identity_config(config.get_identity_config_by_identity(identity.as_str()).unwrap()); + println!(" EMAIL {}", email); Ok(()) } +/// Executes the `identity recover` command which recovers an identity from an email. async fn exec_recover(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let email = args.get_one::("email").unwrap(); - let identity = args.get_one::("identity").unwrap().to_lowercase(); + let identity = config + .resolve_name_to_identity(args.get_one::("identity").map(|s| s.as_str())) + .unwrap(); let query_params = vec![ ("email", email.as_str()), @@ -525,12 +554,12 @@ async fn exec_recover(mut config: Config, args: &ArgMatches) -> Result<(), anyho identity: response.identity.clone(), token: response.token, }; - config.identity_configs_mut().push(identity_config); + config.identity_configs_mut().push(identity_config.clone()); config.update_default_identity(); config.save(); println!("Success. Identity imported."); - // TODO(jdetter): standardize this output - println!(" IDENTITY {}", response.identity); + print_identity_config(&identity_config); + // TODO: Remove this once print_identity_config prints email println!(" EMAIL {}", email); return Ok(()); } diff --git a/crates/cli/src/subcommands/logs.rs b/crates/cli/src/subcommands/logs.rs index 1ce50f4d471..ab9259adc15 100644 --- a/crates/cli/src/subcommands/logs.rs +++ b/crates/cli/src/subcommands/logs.rs @@ -20,7 +20,6 @@ pub fn cli() -> clap::Command { .help("The domain or address of the database to print logs from"), ) .arg( - // TODO(jdetter): unify this with identity + name Arg::new("identity") .long("identity") .short('i') @@ -87,9 +86,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let database = args.get_one::("database").unwrap(); let follow = args.get_flag("follow"); - let identity = args.get_one::("identity"); - - let auth_header = get_auth_header(&mut config, false, identity.map(|x| x.as_str())) + let cloned_config = config.clone(); + let identity = cloned_config.resolve_name_to_identity(args.get_one::("identity").map(|x| x.as_str())); + let auth_header = get_auth_header(&mut config, false, identity.as_deref()) .await .map(|x| x.0); diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 119fc4a2090..93cac329dc4 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -46,7 +46,6 @@ pub fn cli() -> clap::Command { .action(SetTrue), ) // TODO(tyler): We should be able to pass in either an identity or an alias here - // TODO(jdetter): Unify identity + identity alias .arg( Arg::new("identity") .long("identity") @@ -92,7 +91,8 @@ pub fn cli() -> clap::Command { } pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let identity = args.get_one::("identity"); + let cloned_config = config.clone(); + let identity = cloned_config.resolve_name_to_identity(args.get_one::("identity").map(|s| s.as_str())); let name_or_address = args.get_one::("name|address"); let path_to_project = args.get_one::("path_to_project").unwrap(); let host_type = args.get_one::("host_type").unwrap(); @@ -150,7 +150,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } if let Some((auth_header, chosen_identity)) = - get_auth_header(&mut config, anon_identity, identity.map(|x| x.as_str())).await + get_auth_header(&mut config, anon_identity, identity.as_deref()).await { builder = builder.header("Authorization", auth_header); Some(chosen_identity) diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index d0e6223dd8d..f449ed9b53a 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -146,16 +146,24 @@ pub async fn init_default(config: &mut Config, nickname: Option) -> Resu /// or create and save a new default identity. pub async fn select_identity_config( config: &mut Config, - identity: Option<&str>, + identity_or_name: Option<&str>, ) -> Result { - if let Some(identity) = identity { - if let Some(identity_config) = config.get_identity_config_by_identity(identity) { - Ok(identity_config.clone()) + let resolve_identity_to_identity_config = |ident: &str| -> Result { + config + .get_identity_config_by_identity(ident) + .map(Clone::clone) + .ok_or_else(|| anyhow::anyhow!("Missing identity credentials for identity: {}", ident)) + }; + + if let Some(identity_or_name) = identity_or_name { + if is_hex_identity(identity_or_name) { + resolve_identity_to_identity_config(identity_or_name) } else { - Err(anyhow::anyhow!( - "Missing identity credentials for identity: {}", - identity - )) + // First check to see if we can convert the name to an identity, then return the config for that identity + match config.resolve_name_to_identity(Some(identity_or_name)) { + None => Err(anyhow::anyhow!("No such identity for name: {}", identity_or_name,)), + Some(identity) => resolve_identity_to_identity_config(&identity), + } } } else { Ok(init_default(config, None).await?.identity_config) @@ -177,10 +185,10 @@ pub async fn select_identity_config( pub async fn get_auth_header( config: &mut Config, anon_identity: bool, - identity: Option<&str>, + identity_or_name: Option<&str>, ) -> Option<(String, Identity)> { if !anon_identity { - let identity_config = match select_identity_config(config, identity).await { + let identity_config = match select_identity_config(config, identity_or_name).await { Ok(ic) => ic, Err(err) => { println!("{}", err); @@ -205,4 +213,20 @@ pub async fn get_auth_header( } } +pub fn is_hex_identity(ident: &str) -> bool { + ident.len() == 64 && ident.chars().all(|c| c.is_ascii_hexdigit()) +} + +pub fn print_identity_config(ident: &IdentityConfig) { + println!(" IDENTITY {}", ident.identity); + println!( + " NAME {}", + match &ident.nickname { + None => "", + Some(name) => name.as_str(), + } + ); + // TODO: lookup email here when we have an API endpoint for it +} + pub const VALID_PROTOCOLS: [&str; 2] = ["http", "https"]; diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index 26147ae0528..08220198d78 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -9,14 +9,28 @@ use spacetimedb_lib::Identity; use crate::auth::{SpacetimeAuth, SpacetimeAuthHeader}; use crate::{log_and_500, ControlCtx, ControlNodeDelegate}; +#[derive(Deserialize)] +pub struct CreateIdentityQueryParams { + email: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateIdentityResponse { identity: String, token: String, } -pub async fn create_identity(State(ctx): State>) -> axum::response::Result { +pub async fn create_identity( + State(ctx): State>, + Query(CreateIdentityQueryParams { email }): Query, +) -> axum::response::Result { let auth = SpacetimeAuth::alloc(&*ctx).await?; + if let Some(email) = email { + ctx.control_db() + .associate_email_spacetime_identity(auth.identity, email.as_str()) + .await + .unwrap(); + } let identity_response = CreateIdentityResponse { identity: auth.identity.to_hex(), diff --git a/test/tests/identity-new-email.sh b/test/tests/identity-new-email.sh new file mode 100644 index 00000000000..7bbd8fa340a --- /dev/null +++ b/test/tests/identity-new-email.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +if [ "$DESCRIBE_TEST" = 1 ] ; then + echo "This test is designed to make sure an email can be set while creating a new identity" + exit +fi + +set -euox pipefail + +source "./test/lib.include" + +# Create a new identity +EMAIL="$(random_string)@clockworklabs.io" +run_test cargo run identity new --email "$EMAIL" +IDENT=$(grep IDENTITY "$TEST_OUT" | awk '{print $2}') +TOKEN=$(grep token "$HOME/.spacetime/config.toml" | awk '{print $3}' | tr -d \') + +# Reset our config so we lose this identity +reset_config + +# Import this identity, and set it as the default identity +run_test cargo run identity import "$IDENT" "$TOKEN" +run_test cargo run identity set-default "$IDENT" + +# Configure our email +run_test cargo run identity set-email "$IDENT" "$EMAIL" +[ "$IDENT" == "$(grep IDENTITY "$TEST_OUT" | awk '{print $2}')" ] +[ "$EMAIL" == "$(grep EMAIL "$TEST_OUT" | awk '{print $2}')" ] + +# Reset config again +reset_config + +# Find our identity by its email +run_test cargo run identity find "$EMAIL" +[ "$IDENT" == "$(grep IDENTITY "$TEST_OUT" | awk '{print $2}')" ] +[ "$EMAIL" == "$(grep EMAIL "$TEST_OUT" | awk '{print $2}')" ] diff --git a/test/tests/identity-remove.sh b/test/tests/identity-remove.sh index 2be51d2f8a4..3a60d802f85 100644 --- a/test/tests/identity-remove.sh +++ b/test/tests/identity-remove.sh @@ -16,8 +16,8 @@ IDENT=$(grep IDENTITY "$TEST_OUT" | awk '{print $2}') run_test cargo run identity list [ "1" == "$(grep -c "$IDENT" "$TEST_OUT")" ] -run_test cargo run identity remove --identity "$IDENT" +run_test cargo run identity remove "$IDENT" run_test cargo run identity list [ "0" == "$(grep -c "$IDENT" "$TEST_OUT")" ] -run_fail_test cargo run identity remove --identity "$IDENT" +run_fail_test cargo run identity remove "$IDENT" diff --git a/test/tests/identity-set-default.sh b/test/tests/identity-set-default.sh index 746ef88e3d3..b599b295753 100644 --- a/test/tests/identity-set-default.sh +++ b/test/tests/identity-set-default.sh @@ -15,7 +15,7 @@ run_test cargo run identity new --no-email IDENT=$(grep IDENTITY "$TEST_OUT" | awk '{print $2}') run_test cargo run identity list [ "0" == "$(grep -F "***" "$TEST_OUT" | grep -c "$IDENT")" ] -run_test cargo run identity set-default --identity "$IDENT" +run_test cargo run identity set-default "$IDENT" run_test cargo run identity list [ "1" == "$(grep -F "***" "$TEST_OUT" | grep -c "$IDENT")" ] diff --git a/test/tests/identity-tests.sh b/test/tests/identity-set-email.sh similarity index 80% rename from test/tests/identity-tests.sh rename to test/tests/identity-set-email.sh index a8467a67be1..c8026c15a8e 100644 --- a/test/tests/identity-tests.sh +++ b/test/tests/identity-set-email.sh @@ -1,8 +1,8 @@ #!/bin/bash if [ "$DESCRIBE_TEST" = 1 ] ; then - echo 'This test is designed to test the "spacetime identity" subcommand.' - exit + echo 'This test is designed to test the identity set-email functionality' + exit fi set -euox pipefail @@ -20,10 +20,10 @@ reset_config # Import this identity, and set it as the default identity run_test cargo run identity import "$IDENT" "$TOKEN" -run_test cargo run identity set-default --identity "$IDENT" +run_test cargo run identity set-default "$IDENT" # Configure our email -run_test cargo run identity set-email --identity "$IDENT" "$EMAIL" +run_test cargo run identity set-email "$IDENT" "$EMAIL" [ "$IDENT" == "$(grep IDENTITY "$TEST_OUT" | awk '{print $2}')" ] [ "$EMAIL" == "$(grep EMAIL "$TEST_OUT" | awk '{print $2}')" ] diff --git a/test/tests/permissions-call.sh b/test/tests/permissions-call.sh index 547182b6ef8..c90b67e9d4d 100644 --- a/test/tests/permissions-call.sh +++ b/test/tests/permissions-call.sh @@ -23,6 +23,6 @@ run_test cargo run call "$DATABASE" "say_hello" reset_config run_test cargo run identity import "$IDENT" "$TOKEN" -run_test cargo run identity set-default --identity "$IDENT" +run_test cargo run identity set-default "$IDENT" run_test cargo run logs "$DATABASE" 10000 if [ "1" != "$(grep -c "World" "$TEST_OUT")" ]; then exit 1; fi diff --git a/test/tests/permissions-logs.sh b/test/tests/permissions-logs.sh index 5e12a75660a..c42a9591305 100644 --- a/test/tests/permissions-logs.sh +++ b/test/tests/permissions-logs.sh @@ -22,6 +22,6 @@ run_test cargo run call "$DATABASE" "say_hello" reset_config run_test cargo run identity new --no-email IDENT=$(grep IDENTITY "$TEST_OUT" | awk '{print $2}') -run_test cargo run identity set-default --identity "$IDENT" +run_test cargo run identity set-default "$IDENT" if run_test cargo run logs "$DATABASE" 10000 ; then exit 1; fi if [ "0" != "$(grep -c "World" "$TEST_OUT")" ]; then exit 1; fi diff --git a/test/tests/permissions-publish.sh b/test/tests/permissions-publish.sh index 1179bff22dd..e5fc7404aba 100644 --- a/test/tests/permissions-publish.sh +++ b/test/tests/permissions-publish.sh @@ -12,7 +12,7 @@ source "./test/lib.include" create_project run_test cargo run identity new --no-email IDENT=$(grep IDENTITY "$TEST_OUT" | awk '{print $2}') -run_test cargo run identity set-default --identity "$IDENT" +run_test cargo run identity set-default "$IDENT" run_test cargo run publish -s -d --project-path="$PROJECT_PATH" --clear-database ADDRESS="$(grep "reated new database" "$TEST_OUT" | awk 'NF>1{print $NF}')"