10 files changed
+167
-14
crates/cli/src/cli/mod.rs
+6
-1
@@ -1,5 +1,5 @@
1
use {
2
- crate::{account, cloud_auth, mail, project},
2
+ crate::{account, cloud_auth, mail, project, tenant},
3
clap::{Parser, Subcommand},
4
smbcloud_network::environment::Environment,
5
spinners::Spinner,
@@ -81,6 +81,11 @@ pub enum Commands {
81
#[clap(subcommand)]
82
command: project::cli::Commands,
83
},
84
+ #[clap(about = "Manage your tenants.")]
85
+ Tenant {
86
+ #[clap(subcommand)]
87
+ command: tenant::cli::Commands,
88
+ },
89
#[clap(about = "Manage smbCloud Mail.")]
90
Mail {
91
#[clap(subcommand)]
crates/cli/src/lib.rs
+1
@@ -12,6 +12,7 @@ pub mod interface;
12
pub mod mail;
13
pub mod mcp;
14
pub mod project;
15
+pub mod tenant;
16
mod token;
17
pub mod ui;
18
crates/cli/src/main.rs
+3
@@ -10,6 +10,7 @@ use {
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},
13
+ tenant::process::process_tenant,
14
},
15
smbcloud_network::{environment::Environment, network::check_internet_connection},
16
std::{
@@ -180,6 +181,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
181
| Some(Commands::Mail { .. })
182
| Some(Commands::Auth { .. })
183
| Some(Commands::Project { .. })
184
+ | Some(Commands::Tenant { .. })
185
| Some(Commands::Migrate {})
186
| None => true,
187
Some(Commands::Init {}) => true,
@@ -202,6 +204,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
204
Some(Commands::Mail { command }) => process_mail(cli.environment, command).await,
205
Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await,
206
Some(Commands::Project { command }) => process_project(cli.environment, command).await,
207
+ Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await,
208
Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
209
None => process_deploy(cli.environment, None).await,
210
}
crates/cli/src/project/crud_delete.rs
+13
-5
@@ -3,11 +3,13 @@ use crate::token::get_smb_token::get_smb_token;
3
use crate::{
4
account::lib::is_logged_in,
5
cli::CommandResult,
6
- ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
6
+ ui::{fail_message, fail_symbol, prompt::confirm_delete_typed, succeed_message, succeed_symbol},
7
};
8
use anyhow::{anyhow, Result};
9
use smbcloud_network::environment::Environment;
10
-use smbcloud_networking_project::crud_project_delete::delete_project;
10
+use smbcloud_networking_project::{
11
+ crud_project_delete::delete_project, crud_project_read::get_project,
12
+};
13
use spinners::Spinner;
14
15
pub async fn process_project_delete(env: Environment, id: String) -> Result<CommandResult> {
@@ -15,9 +17,16 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
17
return Err(anyhow!(fail_message("Please log in with `smb init`.")));
18
}
19
18
- let confirmed = confirm_delete(
20
+ let access_token = get_smb_token(env)?;
21
+ let project = get_project(env, client(), access_token.clone(), id.clone()).await?;
22
+
23
+ let confirmed = confirm_delete_typed(
24
"Project deletion confirmation",
20
- &format!("Delete project #{id}"),
25
+ &format!(
26
+ "This permanently deletes project #{} and everything under it — repos, apps, and deployment history.",
27
+ project.id
28
+ ),
29
+ &project.name,
30
)?;
31
32
let spinner = Spinner::new(
@@ -32,7 +41,6 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
41
msg: succeed_message("Cancelled."),
42
});
43
}
35
- let access_token = get_smb_token(env)?;
44
match delete_project(env, client(), access_token, id).await {
45
Ok(_) => Ok(CommandResult {
46
spinner,
crates/cli/src/tenant/mod.rs
new
+6
@@ -0,0 +1,6 @@
1
+pub mod cli;
2
+pub mod process;
3
+mod render;
4
+mod request;
5
+mod tenant_client;
6
+mod url_builder;
crates/cli/src/tenant/process.rs
+23
-5
@@ -7,10 +7,10 @@ use crate::{
7
tenant_client::{create_tenant, delete_tenant, get_tenant, get_tenants, update_tenant},
8
},
9
token::get_smb_token::get_smb_token,
10
- ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol},
10
+ ui::{fail_message, fail_symbol, prompt::confirm_delete_double, succeed_message, succeed_symbol},
11
};
12
use anyhow::{anyhow, Result};
13
-use smbcloud_model::tenant::{TenantCreate, TenantUpdate};
13
+use smbcloud_model::tenant::{TenantCreate, TenantKind, TenantUpdate};
14
use smbcloud_network::environment::Environment;
15
use spinners::Spinner;
16
@@ -98,16 +98,34 @@ async fn process_tenant_update(env: Environment, id: String, name: String) -> Re
98
99
async fn process_tenant_delete(env: Environment, id: String) -> Result<CommandResult> {
100
let tenant_id = normalize_required("Tenant id", id)?;
101
- let confirmed = confirm_delete(
101
+ let access_token = get_smb_token(env)?;
102
+
103
+ let tenant = get_tenant(env, client(), access_token.clone(), tenant_id.clone())
104
+ .await
105
+ .map_err(api_error)?;
106
+
107
+ // The personal tenant is bootstrapped on signup and isn't a
108
+ // user-manageable resource — mirror the API-side guard client-side so
109
+ // the failure is immediate instead of after two confirmation prompts.
110
+ if matches!(tenant.kind, TenantKind::Personal) {
111
+ return Err(anyhow!("The personal tenant can't be deleted."));
112
+ }
113
+
114
+ let warning = format!(
115
+ "This permanently deletes \"{}\" and everything it owns — {} project(s), plus its auth apps, mail apps, and domains.",
116
+ tenant.name, tenant.projects_count
117
+ );
118
+ let confirmed = confirm_delete_double(
119
"Tenant deletion confirmation",
103
- &format!("Delete tenant #{tenant_id}"),
120
+ &warning,
121
+ &tenant.slug,
122
+ "delete my tenant",
123
)?;
124
125
if !confirmed {
126
return Ok(done_result("Cancelled."));
127
}
128
110
- let access_token = get_smb_token(env)?;
129
let spinner = loading_spinner("Deleting tenant");
130
131
match delete_tenant(env, client(), access_token, tenant_id).await {
crates/cli/src/ui/prompt.rs
+77
@@ -20,6 +20,7 @@ use {
20
ui::{confirm_dialog::confirm_delete_tui, fail_message},
21
},
22
anyhow::{anyhow, Result},
23
+ console::style,
24
dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Password, Select},
25
};
26
@@ -45,6 +46,82 @@ pub fn confirm_delete(what: &str, message: &str) -> Result<bool> {
46
confirm(message, false)
47
}
48
49
+fn print_danger_warning(warning: &str) {
50
+ println!();
51
+ println!(
52
+ "{}",
53
+ style("⚠ This action cannot be undone.").red().bold()
54
+ );
55
+ println!("{warning}");
56
+ println!();
57
+}
58
+
59
+/// Type-to-confirm deletion, mirroring the "type the resource name to
60
+/// confirm" pattern used for irreversible deletes with real blast radius
61
+/// (Vercel's project/team deletion, GitHub's repo deletion, …). A plain y/n
62
+/// is too easy to reflex through; requiring the caller to type the resource's
63
+/// own identifier back forces them to read it. Refuses in CI, like
64
+/// [`confirm_delete`] — a typed confirmation can't be safely defaulted. Under
65
+/// `--tui`, falls back to the boolean danger-zone dialog (no TUI text-input
66
+/// widget yet).
67
+pub fn confirm_delete_typed(what: &str, warning: &str, resource_identifier: &str) -> Result<bool> {
68
+ if is_ci() {
69
+ return Err(ci_required(what));
70
+ }
71
+ if is_tui() {
72
+ return confirm_delete_tui(warning).map_err(|e| anyhow!(fail_message(&e.to_string())));
73
+ }
74
+
75
+ print_danger_warning(warning);
76
+
77
+ let typed = Input::<String>::with_theme(&ColorfulTheme::default())
78
+ .with_prompt(format!("Type \"{resource_identifier}\" to confirm"))
79
+ .allow_empty(true)
80
+ .interact()
81
+ .map_err(io_error)?;
82
+
83
+ Ok(typed.trim() == resource_identifier)
84
+}
85
+
86
+/// Two-step type-to-confirm deletion for the highest-blast-radius actions —
87
+/// ones that cascade across many owned resources (a tenant owns projects,
88
+/// auth apps, mail apps, domains, …). First the caller types the resource's
89
+/// own identifier (proves they're looking at the right thing), then a fixed
90
+/// intent phrase (proves they mean to delete it, not just rename/inspect it).
91
+/// Same CI/`--tui` handling as [`confirm_delete_typed`].
92
+pub fn confirm_delete_double(
93
+ what: &str,
94
+ warning: &str,
95
+ resource_identifier: &str,
96
+ intent_phrase: &str,
97
+) -> Result<bool> {
98
+ if is_ci() {
99
+ return Err(ci_required(what));
100
+ }
101
+ if is_tui() {
102
+ return confirm_delete_tui(warning).map_err(|e| anyhow!(fail_message(&e.to_string())));
103
+ }
104
+
105
+ print_danger_warning(warning);
106
+
107
+ let typed_identifier = Input::<String>::with_theme(&ColorfulTheme::default())
108
+ .with_prompt(format!("Type \"{resource_identifier}\" to confirm"))
109
+ .allow_empty(true)
110
+ .interact()
111
+ .map_err(io_error)?;
112
+ if typed_identifier.trim() != resource_identifier {
113
+ return Ok(false);
114
+ }
115
+
116
+ let typed_phrase = Input::<String>::with_theme(&ColorfulTheme::default())
117
+ .with_prompt(format!("Type \"{intent_phrase}\" to confirm"))
118
+ .allow_empty(true)
119
+ .interact()
120
+ .map_err(io_error)?;
121
+
122
+ Ok(typed_phrase.trim() == intent_phrase)
123
+}
124
+
125
/// Yes/no confirmation. In CI mode returns `default` without prompting.
126
pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
127
if is_ci() {
crates/smbcloud-networking-project/src/crud_project_delete.rs
+3
-3
@@ -1,7 +1,7 @@
1
-use crate::url_builder::build_project_url_with_id;
1
+use crate::{request::request_empty, url_builder::build_project_url_with_id};
2
use reqwest::Client;
3
use smbcloud_model::error_codes::ErrorResponse;
4
-use smbcloud_network::{environment::Environment, network::request};
4
+use smbcloud_network::environment::Environment;
5
use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
6
7
pub async fn delete_project(
@@ -14,5 +14,5 @@ pub async fn delete_project(
14
.delete(build_project_url_with_id(env, client, id))
15
.header("Authorization", access_token)
16
.header("User-agent", SMB_USER_AGENT);
17
- request(builder).await
17
+ request_empty(builder).await
18
}
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -10,4 +10,5 @@ pub mod crud_project_deployment_read;
10
pub mod crud_project_deployment_update;
11
pub mod crud_project_read;
12
pub mod crud_project_update;
13
+mod request;
14
mod url_builder;
crates/smbcloud-networking-project/src/request.rs
new
+34
@@ -0,0 +1,34 @@
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
+/// Like `smbcloud_network::network::request`, but for endpoints that reply
7
+/// with an empty body (e.g. `DELETE` returning `204 No Content`). The generic
8
+/// `request` only treats `200`/`201` as success and then tries to parse the
9
+/// body as JSON, so it fails on a bare `204`.
10
+pub(crate) async fn request_empty(builder: RequestBuilder) -> Result<(), ErrorResponse> {
11
+ if !check_internet_connection().await {
12
+ return Err(ErrorResponse::Error {
13
+ error_code: ErrorCode::NetworkError,
14
+ message: "No internet connection. Please check your network settings and try again."
15
+ .to_string(),
16
+ });
17
+ }
18
+
19
+ let response = builder.send().await.map_err(|_| ErrorResponse::Error {
20
+ error_code: ErrorCode::NetworkError,
21
+ message: ErrorCode::NetworkError.message(None).to_string(),
22
+ })?;
23
+
24
+ match response.status() {
25
+ StatusCode::OK | StatusCode::CREATED | StatusCode::NO_CONTENT => Ok(()),
26
+ _ => match parse_error_response::<Value>(response).await {
27
+ Ok(_) => Err(ErrorResponse::Error {
28
+ error_code: ErrorCode::ParseError,
29
+ message: ErrorCode::ParseError.message(None).to_string(),
30
+ }),
31
+ Err(error_response) => Err(error_response),
32
+ },
33
+ }
34
+}