diff --git a/Cargo.lock b/Cargo.lock index 4acb31d..49188c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,6 +341,7 @@ dependencies = [ "once_cell", "rand 0.8.5", "reqwest", + "rpassword", "serde", "serde_json", "sha2", @@ -1874,6 +1875,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + [[package]] name = "rsa" version = "0.9.9" @@ -1894,6 +1906,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3053,6 +3075,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index 1b9388f..21846a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ block-id = "0.2.1" chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls"] } +rpassword = "7.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" once_cell = "1.19" diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 75be470..c8a93ef 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,11 +1,13 @@ pub mod roasters; pub mod roasts; +pub mod tokens; use std::net::SocketAddr; use clap::{Args, Parser, Subcommand}; use roasters::{AddRoasterCommand, DeleteRoasterCommand, GetRoasterCommand, UpdateRoasterCommand}; use roasts::{AddRoastCommand, DeleteRoastCommand, GetRoastCommand, ListRoastsCommand}; +use tokens::{CreateTokenCommand, RevokeTokenCommand}; #[derive(Debug, Parser)] #[command(author, version, about = "Track coffee roasts, brews, and cups", long_about = None)] @@ -27,6 +29,14 @@ pub enum Commands { #[command(name = "serve")] Serve(ServeCommand), + // Tokens + #[command(name = "create-token")] + CreateToken(CreateTokenCommand), + #[command(name = "list-tokens")] + ListTokens, + #[command(name = "revoke-token")] + RevokeToken(RevokeTokenCommand), + // Roasters #[command(name = "add-roaster")] AddRoaster(AddRoasterCommand), diff --git a/src/cli/tokens.rs b/src/cli/tokens.rs new file mode 100644 index 0000000..8b2e6f5 --- /dev/null +++ b/src/cli/tokens.rs @@ -0,0 +1,60 @@ +use anyhow::{Context, Result}; +use clap::Args; +use std::io::{self, Write}; + +use crate::cli::print_json; +use crate::client::BrewlogClient; + +#[derive(Debug, Args)] +pub struct CreateTokenCommand { + /// A descriptive name for this token + #[arg(long)] + pub name: String, +} + +#[derive(Debug, Args)] +pub struct RevokeTokenCommand { + /// The ID of the token to revoke + #[arg(long)] + pub id: String, +} + +pub async fn create_token(client: &BrewlogClient, cmd: CreateTokenCommand) -> Result<()> { + // Prompt for username + print!("Username: "); + io::stdout().flush()?; + let mut username = String::new(); + io::stdin().read_line(&mut username)?; + let username = username.trim(); + + // Prompt for password (without echo) + let password = rpassword::prompt_password("Password: ") + .context("failed to read password")?; + + // Create the token + let token_response = client + .tokens() + .create(&username, &password, &cmd.name) + .await?; + + println!("\nToken created successfully!"); + println!("Token ID: {}", token_response.id); + println!("Token Name: {}", token_response.name); + println!("\n⚠️ Save this token securely - it will not be shown again:"); + println!("\n{}", token_response.token); + println!("\nExport it in your environment:"); + println!(" export BREWLOG_TOKEN={}", token_response.token); + + Ok(()) +} + +pub async fn list_tokens(client: &BrewlogClient) -> Result<()> { + let tokens = client.tokens().list().await?; + print_json(&tokens) +} + +pub async fn revoke_token(client: &BrewlogClient, cmd: RevokeTokenCommand) -> Result<()> { + let token = client.tokens().revoke(&cmd.id).await?; + println!("Token revoked successfully"); + print_json(&token) +} diff --git a/src/client/mod.rs b/src/client/mod.rs index 7e13fca..878f7a7 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,5 +1,6 @@ pub mod roasters; pub mod roasts; +pub mod tokens; use anyhow::{Context, Result, anyhow}; use reqwest::{Client, Url}; @@ -9,6 +10,7 @@ use crate::server::errors::ErrorResponse; pub struct BrewlogClient { base_url: Url, http: Client, + token: Option, } impl BrewlogClient { @@ -18,6 +20,8 @@ impl BrewlogClient { normalized.set_path(&format!("{}/", normalized.path().trim_end_matches('/'))); } + let token = std::env::var("BREWLOG_TOKEN").ok(); + let http = Client::builder() .user_agent("brewlog-cli/0.1") .build() @@ -26,6 +30,7 @@ impl BrewlogClient { Ok(Self { base_url: normalized, http, + token, }) } @@ -42,6 +47,10 @@ impl BrewlogClient { roasts::RoastsClient::new(self) } + pub fn tokens(&self) -> tokens::TokensClient<'_> { + tokens::TokensClient::new(self) + } + pub(crate) fn endpoint(&self, path: &str) -> Result { self.base_url .join(path) @@ -52,6 +61,15 @@ impl BrewlogClient { &self.http } + /// Build a request with authentication if token is available + pub(crate) fn request(&self, method: reqwest::Method, url: Url) -> reqwest::RequestBuilder { + let mut request = self.http.request(method, url); + if let Some(token) = &self.token { + request = request.bearer_auth(token); + } + request + } + pub(crate) async fn handle_response(&self, response: reqwest::Response) -> Result where T: serde::de::DeserializeOwned, diff --git a/src/client/tokens.rs b/src/client/tokens.rs new file mode 100644 index 0000000..37cfd69 --- /dev/null +++ b/src/client/tokens.rs @@ -0,0 +1,69 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use crate::client::BrewlogClient; +use crate::domain::tokens::Token; + +pub struct TokensClient<'a> { + client: &'a BrewlogClient, +} + +impl<'a> TokensClient<'a> { + pub fn new(client: &'a BrewlogClient) -> Self { + Self { client } + } + + pub async fn create( + &self, + username: &str, + password: &str, + name: &str, + ) -> Result { + let url = self.client.endpoint("api/v1/tokens")?; + let body = CreateTokenRequest { + username: username.to_string(), + password: password.to_string(), + name: name.to_string(), + }; + + let response = self + .client + .http_client() + .post(url) + .json(&body) + .send() + .await?; + + self.client.handle_response(response).await + } + + pub async fn list(&self) -> Result> { + let url = self.client.endpoint("api/v1/tokens")?; + + let response = self.client.http_client().get(url).send().await?; + + self.client.handle_response(response).await + } + + pub async fn revoke(&self, id: &str) -> Result { + let url = self.client.endpoint(&format!("api/v1/tokens/{}/revoke", id))?; + + let response = self.client.http_client().post(url).send().await?; + + self.client.handle_response(response).await + } +} + +#[derive(Debug, Serialize)] +struct CreateTokenRequest { + username: String, + password: String, + name: String, +} + +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub id: String, + pub name: String, + pub token: String, +} diff --git a/src/main.rs b/src/main.rs index 3184a72..b47e07e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts}; +use brewlog::cli::{Cli, Commands, ServeCommand, roasters, roasts, tokens}; use brewlog::client::BrewlogClient; use brewlog::server::{ServerConfig, serve}; use clap::Parser; @@ -18,6 +18,11 @@ async fn main() -> Result<()> { command => { let client = BrewlogClient::from_base_url(&cli.api_url)?; match command { + // Tokens + Commands::CreateToken(cmd) => tokens::create_token(&client, cmd).await, + Commands::ListTokens => tokens::list_tokens(&client).await, + Commands::RevokeToken(cmd) => tokens::revoke_token(&client, cmd).await, + // Roasters Commands::AddRoaster(cmd) => roasters::add_roaster(&client, cmd).await, Commands::ListRoasters => roasters::list_roasters(&client).await,