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
}