@smbcloud / cli / commits / 45310e0

Add smbCloud Auth CRUD

Seto Elkahfi committed Jul 20, 2026 at 18:08 UTC 45310e0e647fa473b7d92875cc5f687be9accb8e
31 files changed +459 -5
crates/cli/src/cli/mod.rs
+6 -1
@@ -1,5 +1,5 @@
1 use {
2 - crate::{account, mail, project},
2 + crate::{account, cloud_auth, mail, project},
3 clap::{Parser, Subcommand},
4 smbcloud_network::environment::Environment,
5 spinners::Spinner,
@@ -86,6 +86,11 @@ pub enum Commands {
86 #[clap(subcommand)]
87 command: mail::cli::Commands,
88 },
89 + #[clap(about = "Manage smbCloud Auth apps.")]
90 + Auth {
91 + #[clap(subcommand)]
92 + command: cloud_auth::cli::Commands,
93 + },
94 #[clap(
95 about = "Migrate local .smb/config.toml deploy fields to the smbCloud server.",
96 display_order = 4
crates/cli/src/cloud-auth/auth_app.rs new
+85
@@ -0,0 +1,85 @@
1 +use crate::cloud_auth::{
2 + request::request_empty,
3 + url_builder::{build_auth_app_url, build_auth_apps_url},
4 +};
5 +use reqwest::Client;
6 +use serde::Serialize;
7 +use smbcloud_model::{
8 + app_auth::{AuthApp, AuthAppCreate, AuthAppUpdate},
9 + error_codes::ErrorResponse,
10 +};
11 +use smbcloud_network::{environment::Environment, network::request};
12 +use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
13 +
14 +#[derive(Serialize)]
15 +struct AuthAppEnvelope<T> {
16 + auth_app: T,
17 +}
18 +
19 +pub async fn get_auth_apps(
20 + env: Environment,
21 + client: (&SmbClient, &str),
22 + access_token: String,
23 + project_id: Option<String>,
24 +) -> Result<Vec<AuthApp>, ErrorResponse> {
25 + let builder = Client::new()
26 + .get(build_auth_apps_url(env, client, project_id.as_deref()))
27 + .header("Authorization", access_token)
28 + .header("User-agent", SMB_USER_AGENT);
29 + request(builder).await
30 +}
31 +
32 +pub async fn get_auth_app(
33 + env: Environment,
34 + client: (&SmbClient, &str),
35 + access_token: String,
36 + auth_app_id: String,
37 +) -> Result<AuthApp, ErrorResponse> {
38 + let builder = Client::new()
39 + .get(build_auth_app_url(env, client, &auth_app_id))
40 + .header("Authorization", access_token)
41 + .header("User-agent", SMB_USER_AGENT);
42 + request(builder).await
43 +}
44 +
45 +pub async fn create_auth_app(
46 + env: Environment,
47 + client: (&SmbClient, &str),
48 + access_token: String,
49 + auth_app: AuthAppCreate,
50 +) -> Result<AuthApp, ErrorResponse> {
51 + let builder = Client::new()
52 + .post(build_auth_apps_url(env, client, None))
53 + .json(&AuthAppEnvelope { auth_app })
54 + .header("Authorization", access_token)
55 + .header("User-agent", SMB_USER_AGENT);
56 + request(builder).await
57 +}
58 +
59 +pub async fn update_auth_app(
60 + env: Environment,
61 + client: (&SmbClient, &str),
62 + access_token: String,
63 + auth_app_id: String,
64 + auth_app: AuthAppUpdate,
65 +) -> Result<AuthApp, ErrorResponse> {
66 + let builder = Client::new()
67 + .put(build_auth_app_url(env, client, &auth_app_id))
68 + .json(&AuthAppEnvelope { auth_app })
69 + .header("Authorization", access_token)
70 + .header("User-agent", SMB_USER_AGENT);
71 + request(builder).await
72 +}
73 +
74 +pub async fn delete_auth_app(
75 + env: Environment,
76 + client: (&SmbClient, &str),
77 + access_token: String,
78 + auth_app_id: String,
79 +) -> Result<(), ErrorResponse> {
80 + let builder = Client::new()
81 + .delete(build_auth_app_url(env, client, &auth_app_id))
82 + .header("Authorization", access_token)
83 + .header("User-agent", SMB_USER_AGENT);
84 + request_empty(builder).await
85 +}
crates/cli/src/cloud-auth/cli.rs new
+38
@@ -0,0 +1,38 @@
1 +use clap::Subcommand;
2 +
3 +#[derive(Subcommand)]
4 +pub enum Commands {
5 + #[clap(about = "List your Auth apps.")]
6 + List {
7 + #[clap(long)]
8 + project_id: Option<String>,
9 + },
10 + #[clap(about = "Show an Auth app.")]
11 + Show {
12 + #[clap(long, required = true)]
13 + id: String,
14 + },
15 + #[clap(about = "Create an Auth app.")]
16 + New {
17 + #[clap(long, required = true)]
18 + name: String,
19 + #[clap(long)]
20 + project_id: Option<String>,
21 + #[clap(long)]
22 + support_email: Option<String>,
23 + },
24 + #[clap(about = "Update an Auth app.")]
25 + Update {
26 + #[clap(long, required = true)]
27 + id: String,
28 + #[clap(long)]
29 + name: Option<String>,
30 + #[clap(long)]
31 + support_email: Option<String>,
32 + },
33 + #[clap(about = "Delete an Auth app.")]
34 + Delete {
35 + #[clap(long, required = true)]
36 + id: String,
37 + },
38 +}
crates/cli/src/cloud-auth/mod.rs new
+6
@@ -0,0 +1,6 @@
1 +pub mod auth_app;
2 +pub mod cli;
3 +pub mod process;
4 +mod render;
5 +mod request;
6 +mod url_builder;
crates/cli/src/cloud-auth/process.rs new
+191
@@ -0,0 +1,191 @@
1 +use crate::{
2 + cli::CommandResult,
3 + client,
4 + cloud_auth::{
5 + auth_app::{create_auth_app, delete_auth_app, get_auth_app, get_auth_apps, update_auth_app},
6 + cli::Commands,
7 + render::{print_auth_app_detail, print_auth_apps},
8 + },
9 + mail::current_project::{resolve_optional_project_id, resolve_required_project_id},
10 + token::get_smb_token::get_smb_token,
11 + ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
12 +};
13 +use anyhow::{anyhow, Result};
14 +use smbcloud_model::app_auth::{AuthAppCreate, AuthAppUpdate};
15 +use smbcloud_network::environment::Environment;
16 +use spinners::Spinner;
17 +
18 +pub async fn process_cloud_auth(env: Environment, commands: Commands) -> Result<CommandResult> {
19 + match commands {
20 + Commands::List { project_id } => process_auth_app_list(env, project_id).await,
21 + Commands::Show { id } => process_auth_app_show(env, id).await,
22 + Commands::New {
23 + name,
24 + project_id,
25 + support_email,
26 + } => process_auth_app_new(env, name, project_id, support_email).await,
27 + Commands::Update {
28 + id,
29 + name,
30 + support_email,
31 + } => process_auth_app_update(env, id, name, support_email).await,
32 + Commands::Delete { id } => process_auth_app_delete(env, id).await,
33 + }
34 +}
35 +
36 +async fn process_auth_app_list(
37 + env: Environment,
38 + project_id: Option<String>,
39 +) -> Result<CommandResult> {
40 + let access_token = get_smb_token(env)?;
41 + let resolved_project_id = resolve_optional_project_id(env, normalize_optional(project_id))?;
42 + let mut spinner = loading_spinner("Loading Auth apps");
43 +
44 + let auth_apps = get_auth_apps(env, client(), access_token, resolved_project_id)
45 + .await
46 + .map_err(api_error)?;
47 +
48 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
49 + print_auth_apps(&auth_apps);
50 +
51 + Ok(done_result(if auth_apps.is_empty() {
52 + "No Auth apps found."
53 + } else {
54 + "Done."
55 + }))
56 +}
57 +
58 +async fn process_auth_app_show(env: Environment, id: String) -> Result<CommandResult> {
59 + let access_token = get_smb_token(env)?;
60 + let auth_app_id = normalize_required("Auth app id", id)?;
61 + let mut spinner = loading_spinner("Loading Auth app");
62 +
63 + let auth_app = get_auth_app(env, client(), access_token, auth_app_id)
64 + .await
65 + .map_err(api_error)?;
66 +
67 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
68 + print_auth_app_detail(&auth_app);
69 +
70 + Ok(done_result("Done."))
71 +}
72 +
73 +async fn process_auth_app_new(
74 + env: Environment,
75 + name: String,
76 + project_id: Option<String>,
77 + support_email: Option<String>,
78 +) -> Result<CommandResult> {
79 + let access_token = get_smb_token(env)?;
80 + let auth_app = AuthAppCreate {
81 + name: normalize_required("name", name)?,
82 + project_id: resolve_required_project_id(env, normalize_optional(project_id))?,
83 + support_email: normalize_optional(support_email),
84 + };
85 + let mut spinner = loading_spinner("Creating Auth app");
86 +
87 + let auth_app = create_auth_app(env, client(), access_token, auth_app)
88 + .await
89 + .map_err(api_error)?;
90 +
91 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Created."));
92 + print_auth_app_detail(&auth_app);
93 +
94 + Ok(done_result("Auth app created."))
95 +}
96 +
97 +async fn process_auth_app_update(
98 + env: Environment,
99 + id: String,
100 + name: Option<String>,
101 + support_email: Option<String>,
102 +) -> Result<CommandResult> {
103 + let access_token = get_smb_token(env)?;
104 + let auth_app_id = normalize_required("Auth app id", id)?;
105 + let update = AuthAppUpdate {
106 + name: normalize_optional(name),
107 + support_email: normalize_optional(support_email),
108 + };
109 +
110 + if update.is_empty() {
111 + return Err(anyhow!(
112 + "Specify at least one of `--name` or `--support-email`."
113 + ));
114 + }
115 +
116 + let mut spinner = loading_spinner("Updating Auth app");
117 + let auth_app = update_auth_app(env, client(), access_token, auth_app_id, update)
118 + .await
119 + .map_err(api_error)?;
120 +
121 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Updated."));
122 + print_auth_app_detail(&auth_app);
123 +
124 + Ok(done_result("Auth app updated."))
125 +}
126 +
127 +async fn process_auth_app_delete(env: Environment, id: String) -> Result<CommandResult> {
128 + let auth_app_id = normalize_required("Auth app id", id)?;
129 + let confirmed = confirm_delete(
130 + "Auth app deletion confirmation",
131 + &format!("Delete Auth app #{auth_app_id}"),
132 + )?;
133 +
134 + if !confirmed {
135 + return Ok(done_result("Cancelled."));
136 + }
137 +
138 + let access_token = get_smb_token(env)?;
139 + let spinner = loading_spinner("Deleting Auth app");
140 +
141 + match delete_auth_app(env, client(), access_token, auth_app_id).await {
142 + Ok(()) => Ok(CommandResult {
143 + spinner,
144 + symbol: succeed_symbol(),
145 + msg: succeed_message("Done. Auth app has been deleted."),
146 + }),
147 + Err(error) => Ok(CommandResult {
148 + spinner,
149 + symbol: fail_symbol(),
150 + msg: fail_message(&error.to_string()),
151 + }),
152 + }
153 +}
154 +
155 +fn loading_spinner(message: &str) -> Spinner {
156 + Spinner::new(
157 + spinners::Spinners::SimpleDotsScrolling,
158 + succeed_message(message),
159 + )
160 +}
161 +
162 +fn done_result(message: &str) -> CommandResult {
163 + CommandResult {
164 + spinner: loading_spinner("Done"),
165 + symbol: succeed_symbol(),
166 + msg: succeed_message(message),
167 + }
168 +}
169 +
170 +fn normalize_required(label: &str, value: String) -> Result<String> {
171 + let normalized_value = value.trim().to_string();
172 + if normalized_value.is_empty() {
173 + return Err(anyhow!("{} cannot be empty.", label));
174 + }
175 + Ok(normalized_value)
176 +}
177 +
178 +fn normalize_optional(value: Option<String>) -> Option<String> {
179 + value.and_then(|value| {
180 + let normalized_value = value.trim().to_string();
181 + if normalized_value.is_empty() {
182 + None
183 + } else {
184 + Some(normalized_value)
185 + }
186 + })
187 +}
188 +
189 +fn api_error(error: impl std::fmt::Display) -> anyhow::Error {
190 + anyhow!(error.to_string())
191 +}
crates/cli/src/cloud-auth/render.rs new
+45
@@ -0,0 +1,45 @@
1 +use console::style;
2 +use smbcloud_model::app_auth::AuthApp;
3 +
4 +fn print_heading(title: &str) {
5 + println!("\n{}", style(title).bold().underlined());
6 +}
7 +
8 +fn print_field(label: &str, value: impl AsRef<str>) {
9 + println!("{} {}", style(format!("{label}:")).cyan(), value.as_ref());
10 +}
11 +
12 +fn print_optional_field(label: &str, value: Option<&str>) {
13 + if let Some(value) = value {
14 + print_field(label, value);
15 + }
16 +}
17 +
18 +pub(crate) fn print_auth_apps(auth_apps: &[AuthApp]) {
19 + print_heading("Auth apps");
20 +
21 + if auth_apps.is_empty() {
22 + println!("No Auth apps found.");
23 + return;
24 + }
25 +
26 + for auth_app in auth_apps {
27 + println!(
28 + "#{} {} project={}",
29 + auth_app.id,
30 + style(&auth_app.name).bold(),
31 + auth_app.project_id.as_deref().unwrap_or("-")
32 + );
33 + }
34 +}
35 +
36 +pub(crate) fn print_auth_app_detail(auth_app: &AuthApp) {
37 + print_heading("Auth app");
38 + print_field("ID", &auth_app.id);
39 + print_field("Name", &auth_app.name);
40 + print_optional_field("Project ID", auth_app.project_id.as_deref());
41 + print_optional_field("Support email", auth_app.support_email.as_deref());
42 + print_optional_field("Secret", auth_app.secret.as_deref());
43 + print_field("Created at", auth_app.created_at.to_rfc3339());
44 + print_field("Updated at", auth_app.updated_at.to_rfc3339());
45 +}
crates/cli/src/cloud-auth/request.rs new
+30
@@ -0,0 +1,30 @@
1 +use reqwest::{RequestBuilder, StatusCode};
2 +use serde_json::Value;
3 +use smbcloud_model::error_codes::{ErrorCode, ErrorResponse};
4 +use smbcloud_network::network::{check_internet_connection, parse_error_response};
5 +
6 +pub(crate) async fn request_empty(builder: RequestBuilder) -> Result<(), ErrorResponse> {
7 + if !check_internet_connection().await {
8 + return Err(ErrorResponse::Error {
9 + error_code: ErrorCode::NetworkError,
10 + message: "No internet connection. Please check your network settings and try again."
11 + .to_string(),
12 + });
13 + }
14 +
15 + let response = builder.send().await.map_err(|_| ErrorResponse::Error {
16 + error_code: ErrorCode::NetworkError,
17 + message: ErrorCode::NetworkError.message(None).to_string(),
18 + })?;
19 +
20 + match response.status() {
21 + StatusCode::OK | StatusCode::CREATED | StatusCode::NO_CONTENT => Ok(()),
22 + _ => match parse_error_response::<Value>(response).await {
23 + Ok(_) => Err(ErrorResponse::Error {
24 + error_code: ErrorCode::ParseError,
25 + message: ErrorCode::ParseError.message(None).to_string(),
26 + }),
27 + Err(error_response) => Err(error_response),
28 + },
29 + }
30 +}
crates/cli/src/cloud-auth/url_builder.rs new
+28
@@ -0,0 +1,28 @@
1 +use {
2 + smbcloud_network::environment::Environment,
3 + smbcloud_networking::{smb_base_url_builder, smb_client::SmbClient},
4 +};
5 +
6 +pub(crate) fn build_auth_apps_url(
7 + env: Environment,
8 + client: (&SmbClient, &str),
9 + project_id: Option<&str>,
10 +) -> String {
11 + let mut url_builder = smb_base_url_builder(env, client);
12 + url_builder.add_route("v1/auth_apps");
13 + if let Some(project_id) = project_id {
14 + url_builder.add_param("project_id", project_id);
15 + }
16 + url_builder.build()
17 +}
18 +
19 +pub(crate) fn build_auth_app_url(
20 + env: Environment,
21 + client: (&SmbClient, &str),
22 + auth_app_id: &str,
23 +) -> String {
24 + let mut url_builder = smb_base_url_builder(env, client);
25 + url_builder.add_route("v1/auth_apps");
26 + url_builder.add_route(auth_app_id);
27 + url_builder.build()
28 +}
crates/cli/src/cloud-deploy/config.rs renamed
crates/cli/src/cloud-deploy/git.rs renamed
crates/cli/src/cloud-deploy/mod.rs renamed
crates/cli/src/cloud-deploy/process_deploy.rs renamed
crates/cli/src/cloud-deploy/process_deploy_nextjs_ssr.rs renamed
crates/cli/src/cloud-deploy/process_deploy_rails.rs renamed
crates/cli/src/cloud-deploy/process_deploy_rust.rs renamed
crates/cli/src/cloud-deploy/process_deploy_swift.rs renamed
crates/cli/src/cloud-deploy/process_deploy_vite_spa.rs renamed
crates/cli/src/cloud-deploy/process_migrate.rs renamed
crates/cli/src/cloud-deploy/remote_messages.rs renamed
crates/cli/src/cloud-deploy/setup.rs renamed
crates/cli/src/cloud-deploy/setup_create_new_project.rs renamed
crates/cli/src/cloud-deploy/setup_project.rs renamed
crates/cli/src/cloud-deploy/setup_select_project.rs renamed
crates/cli/src/cloud-mail/cli.rs renamed
crates/cli/src/cloud-mail/current_project.rs renamed
crates/cli/src/cloud-mail/mod.rs renamed
+1 -1
@@ -1,4 +1,4 @@
1 pub mod cli;
2 -mod current_project;
2 +pub(crate) mod current_project;
3 pub mod process;
4 mod render;
crates/cli/src/cloud-mail/process.rs renamed
crates/cli/src/cloud-mail/render.rs renamed
crates/cli/src/lib.rs
+4
@@ -3,8 +3,12 @@ use smbcloud_networking::smb_client::SmbClient;
3 pub mod account;
4 pub mod ci;
5 pub mod cli;
6 +#[path = "cloud-auth/mod.rs"]
7 +pub mod cloud_auth;
8 +#[path = "cloud-deploy/mod.rs"]
9 pub mod deploy;
10 pub mod interface;
11 +#[path = "cloud-mail/mod.rs"]
12 pub mod mail;
13 pub mod mcp;
14 pub mod project;
crates/cli/src/main.rs
+3
@@ -6,6 +6,7 @@ use {
6 account::{login::process_login, logout::process_logout, me::process_me, process_account},
7 clear_smb_token,
8 cli::{Cli, CommandResult, Commands},
9 + cloud_auth::process::process_cloud_auth,
10 deploy::{process_deploy::process_deploy, process_migrate::process_migrate},
11 mail::process::process_mail,
12 project::{crud_create::process_project_init, process::process_project},
@@ -177,6 +178,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
178 | Some(Commands::Logout {})
179 | Some(Commands::Account { .. })
180 | Some(Commands::Mail { .. })
181 + | Some(Commands::Auth { .. })
182 | Some(Commands::Project { .. })
183 | Some(Commands::Migrate {})
184 | None => true,
@@ -198,6 +200,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
200 Some(Commands::Login {}) => process_login(cli.environment, None).await,
201 Some(Commands::Logout {}) => process_logout(cli.environment).await,
202 Some(Commands::Mail { command }) => process_mail(cli.environment, command).await,
203 + Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await,
204 Some(Commands::Project { command }) => process_project(cli.environment, command).await,
205 Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
206 None => process_deploy(cli.environment, None).await,
crates/smbcloud-model/src/app_auth.rs
+22 -3
@@ -8,6 +8,8 @@ pub struct AuthApp {
8 pub id: String,
9 pub secret: Option<String>,
10 pub name: String,
11 + pub project_id: Option<String>,
12 + pub support_email: Option<String>,
13 #[serde(with = "ar_date_format")]
14 pub created_at: DateTime<Utc>,
15 #[serde(with = "ar_date_format")]
@@ -17,7 +19,23 @@ pub struct AuthApp {
19 #[derive(Serialize, Deserialize, Debug, Clone)]
20 pub struct AuthAppCreate {
21 pub name: String,
20 - pub description: String,
22 + pub project_id: String,
23 + #[serde(skip_serializing_if = "Option::is_none")]
24 + pub support_email: Option<String>,
25 +}
26 +
27 +#[derive(Serialize, Deserialize, Debug, Clone, Default)]
28 +pub struct AuthAppUpdate {
29 + #[serde(skip_serializing_if = "Option::is_none")]
30 + pub name: Option<String>,
31 + #[serde(skip_serializing_if = "Option::is_none")]
32 + pub support_email: Option<String>,
33 +}
34 +
35 +impl AuthAppUpdate {
36 + pub fn is_empty(&self) -> bool {
37 + self.name.is_none() && self.support_email.is_none()
38 + }
39 }
40
41 /// A public OAuth client registered against an AuthApp for the hosted
@@ -60,11 +78,12 @@ mod tests {
78 fn test_auth_app_create() {
79 let auth_app_create = AuthAppCreate {
80 name: "test".to_owned(),
63 - description: "test".to_owned(),
81 + project_id: "1".to_owned(),
82 + support_email: None,
83 };
84 let json = json!({
85 "name": "test",
67 - "description": "test",
86 + "project_id": "1",
87 });
88 assert_eq!(serde_json::to_value(auth_app_create).unwrap(), json);
89 }