8 files changed
+501
crates/cli/src/tenant/cli.rs
new
+29
@@ -0,0 +1,29 @@
1
+use clap::Subcommand;
2
+
3
+#[derive(Subcommand)]
4
+pub enum Commands {
5
+ #[clap(about = "List your tenants.")]
6
+ List {},
7
+ #[clap(about = "Show a tenant.")]
8
+ Show {
9
+ #[clap(short, long, required = true)]
10
+ id: String,
11
+ },
12
+ #[clap(about = "Create an organization tenant.")]
13
+ New {
14
+ #[clap(short, long, required = true)]
15
+ name: String,
16
+ },
17
+ #[clap(about = "Rename a tenant.")]
18
+ Update {
19
+ #[clap(short, long, required = true)]
20
+ id: String,
21
+ #[clap(short, long, required = true)]
22
+ name: String,
23
+ },
24
+ #[clap(about = "Delete an organization tenant.")]
25
+ Delete {
26
+ #[clap(short, long, required = true)]
27
+ id: String,
28
+ },
29
+}
crates/cli/src/tenant/process.rs
new
+163
@@ -0,0 +1,163 @@
1
+use crate::{
2
+ cli::CommandResult,
3
+ client,
4
+ tenant::{
5
+ cli::Commands,
6
+ render::{print_tenant_detail, print_tenants},
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},
11
+};
12
+use anyhow::{anyhow, Result};
13
+use smbcloud_model::tenant::{TenantCreate, TenantUpdate};
14
+use smbcloud_network::environment::Environment;
15
+use spinners::Spinner;
16
+
17
+pub async fn process_tenant(env: Environment, commands: Commands) -> Result<CommandResult> {
18
+ match commands {
19
+ Commands::List {} => process_tenant_list(env).await,
20
+ Commands::Show { id } => process_tenant_show(env, id).await,
21
+ Commands::New { name } => process_tenant_new(env, name).await,
22
+ Commands::Update { id, name } => process_tenant_update(env, id, name).await,
23
+ Commands::Delete { id } => process_tenant_delete(env, id).await,
24
+ }
25
+}
26
+
27
+async fn process_tenant_list(env: Environment) -> Result<CommandResult> {
28
+ let access_token = get_smb_token(env)?;
29
+ let mut spinner = loading_spinner("Loading tenants");
30
+
31
+ let tenants = get_tenants(env, client(), access_token)
32
+ .await
33
+ .map_err(api_error)?;
34
+
35
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
36
+ print_tenants(&tenants);
37
+
38
+ Ok(done_result(if tenants.is_empty() {
39
+ "No tenants found."
40
+ } else {
41
+ "Done."
42
+ }))
43
+}
44
+
45
+async fn process_tenant_show(env: Environment, id: String) -> Result<CommandResult> {
46
+ let access_token = get_smb_token(env)?;
47
+ let tenant_id = normalize_required("Tenant id", id)?;
48
+ let mut spinner = loading_spinner("Loading tenant");
49
+
50
+ let tenant = get_tenant(env, client(), access_token, tenant_id)
51
+ .await
52
+ .map_err(api_error)?;
53
+
54
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
55
+ print_tenant_detail(&tenant);
56
+
57
+ Ok(done_result("Done."))
58
+}
59
+
60
+async fn process_tenant_new(env: Environment, name: String) -> Result<CommandResult> {
61
+ let access_token = get_smb_token(env)?;
62
+ let tenant = TenantCreate {
63
+ name: normalize_required("name", name)?,
64
+ };
65
+ let mut spinner = loading_spinner("Creating tenant");
66
+
67
+ let tenant = create_tenant(env, client(), access_token, tenant)
68
+ .await
69
+ .map_err(api_error)?;
70
+
71
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Created."));
72
+ print_tenant_detail(&tenant);
73
+
74
+ Ok(done_result("Tenant created."))
75
+}
76
+
77
+async fn process_tenant_update(env: Environment, id: String, name: String) -> Result<CommandResult> {
78
+ let access_token = get_smb_token(env)?;
79
+ let tenant_id = normalize_required("Tenant id", id)?;
80
+ let update = TenantUpdate {
81
+ name: normalize_optional(Some(name)),
82
+ };
83
+
84
+ if update.is_empty() {
85
+ return Err(anyhow!("Specify `--name`."));
86
+ }
87
+
88
+ let mut spinner = loading_spinner("Updating tenant");
89
+ let tenant = update_tenant(env, client(), access_token, tenant_id, update)
90
+ .await
91
+ .map_err(api_error)?;
92
+
93
+ spinner.stop_and_persist(&succeed_symbol(), succeed_message("Updated."));
94
+ print_tenant_detail(&tenant);
95
+
96
+ Ok(done_result("Tenant updated."))
97
+}
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(
102
+ "Tenant deletion confirmation",
103
+ &format!("Delete tenant #{tenant_id}"),
104
+ )?;
105
+
106
+ if !confirmed {
107
+ return Ok(done_result("Cancelled."));
108
+ }
109
+
110
+ let access_token = get_smb_token(env)?;
111
+ let spinner = loading_spinner("Deleting tenant");
112
+
113
+ match delete_tenant(env, client(), access_token, tenant_id).await {
114
+ Ok(()) => Ok(CommandResult {
115
+ spinner,
116
+ symbol: succeed_symbol(),
117
+ msg: succeed_message("Done. Tenant has been deleted."),
118
+ }),
119
+ Err(error) => Ok(CommandResult {
120
+ spinner,
121
+ symbol: fail_symbol(),
122
+ msg: fail_message(&error.to_string()),
123
+ }),
124
+ }
125
+}
126
+
127
+fn loading_spinner(message: &str) -> Spinner {
128
+ Spinner::new(
129
+ spinners::Spinners::SimpleDotsScrolling,
130
+ succeed_message(message),
131
+ )
132
+}
133
+
134
+fn done_result(message: &str) -> CommandResult {
135
+ CommandResult {
136
+ spinner: loading_spinner("Done"),
137
+ symbol: succeed_symbol(),
138
+ msg: succeed_message(message),
139
+ }
140
+}
141
+
142
+fn normalize_required(label: &str, value: String) -> Result<String> {
143
+ let normalized_value = value.trim().to_string();
144
+ if normalized_value.is_empty() {
145
+ return Err(anyhow!("{} cannot be empty.", label));
146
+ }
147
+ Ok(normalized_value)
148
+}
149
+
150
+fn normalize_optional(value: Option<String>) -> Option<String> {
151
+ value.and_then(|value| {
152
+ let normalized_value = value.trim().to_string();
153
+ if normalized_value.is_empty() {
154
+ None
155
+ } else {
156
+ Some(normalized_value)
157
+ }
158
+ })
159
+}
160
+
161
+fn api_error(error: impl std::fmt::Display) -> anyhow::Error {
162
+ anyhow!(error.to_string())
163
+}
crates/cli/src/tenant/render.rs
new
+50
@@ -0,0 +1,50 @@
1
+use console::style;
2
+use smbcloud_model::tenant::Tenant;
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
+pub(crate) fn print_tenants(tenants: &[Tenant]) {
13
+ print_heading("Tenants");
14
+
15
+ if tenants.is_empty() {
16
+ println!("No tenants found.");
17
+ return;
18
+ }
19
+
20
+ for tenant in tenants {
21
+ let marker = if tenant.current { "*" } else { " " };
22
+ println!(
23
+ "{}#{} {} [{}] slug={} role={} projects={}",
24
+ marker,
25
+ tenant.id,
26
+ style(&tenant.name).bold(),
27
+ tenant.kind,
28
+ tenant.slug,
29
+ tenant.role,
30
+ tenant.projects_count
31
+ );
32
+ }
33
+}
34
+
35
+pub(crate) fn print_tenant_detail(tenant: &Tenant) {
36
+ print_heading("Tenant");
37
+ print_field("ID", tenant.id.to_string());
38
+ print_field("Name", &tenant.name);
39
+ print_field("Slug", &tenant.slug);
40
+ print_field("Kind", tenant.kind.to_string());
41
+ print_field("Your role", tenant.role.to_string());
42
+ print_field("Projects", tenant.projects_count.to_string());
43
+ if let Some(default_project) = &tenant.default_project {
44
+ print_field(
45
+ "Default project",
46
+ format!("#{} {}", default_project.id, default_project.name),
47
+ );
48
+ }
49
+ print_field("Created at", tenant.created_at.to_rfc3339());
50
+}
crates/cli/src/tenant/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/tenant/tenant_client.rs
new
+84
@@ -0,0 +1,84 @@
1
+use crate::tenant::{
2
+ request::request_empty,
3
+ url_builder::{build_tenant_url, build_tenants_url},
4
+};
5
+use reqwest::Client;
6
+use serde::Serialize;
7
+use smbcloud_model::{
8
+ error_codes::ErrorResponse,
9
+ tenant::{Tenant, TenantCreate, TenantUpdate},
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 TenantEnvelope<T> {
16
+ tenant: T,
17
+}
18
+
19
+pub async fn get_tenants(
20
+ env: Environment,
21
+ client: (&SmbClient, &str),
22
+ access_token: String,
23
+) -> Result<Vec<Tenant>, ErrorResponse> {
24
+ let builder = Client::new()
25
+ .get(build_tenants_url(env, client))
26
+ .header("Authorization", access_token)
27
+ .header("User-agent", SMB_USER_AGENT);
28
+ request(builder).await
29
+}
30
+
31
+pub async fn get_tenant(
32
+ env: Environment,
33
+ client: (&SmbClient, &str),
34
+ access_token: String,
35
+ tenant_id: String,
36
+) -> Result<Tenant, ErrorResponse> {
37
+ let builder = Client::new()
38
+ .get(build_tenant_url(env, client, &tenant_id))
39
+ .header("Authorization", access_token)
40
+ .header("User-agent", SMB_USER_AGENT);
41
+ request(builder).await
42
+}
43
+
44
+pub async fn create_tenant(
45
+ env: Environment,
46
+ client: (&SmbClient, &str),
47
+ access_token: String,
48
+ tenant: TenantCreate,
49
+) -> Result<Tenant, ErrorResponse> {
50
+ let builder = Client::new()
51
+ .post(build_tenants_url(env, client))
52
+ .json(&TenantEnvelope { tenant })
53
+ .header("Authorization", access_token)
54
+ .header("User-agent", SMB_USER_AGENT);
55
+ request(builder).await
56
+}
57
+
58
+pub async fn update_tenant(
59
+ env: Environment,
60
+ client: (&SmbClient, &str),
61
+ access_token: String,
62
+ tenant_id: String,
63
+ tenant: TenantUpdate,
64
+) -> Result<Tenant, ErrorResponse> {
65
+ let builder = Client::new()
66
+ .patch(build_tenant_url(env, client, &tenant_id))
67
+ .json(&TenantEnvelope { tenant })
68
+ .header("Authorization", access_token)
69
+ .header("User-agent", SMB_USER_AGENT);
70
+ request(builder).await
71
+}
72
+
73
+pub async fn delete_tenant(
74
+ env: Environment,
75
+ client: (&SmbClient, &str),
76
+ access_token: String,
77
+ tenant_id: String,
78
+) -> Result<(), ErrorResponse> {
79
+ let builder = Client::new()
80
+ .delete(build_tenant_url(env, client, &tenant_id))
81
+ .header("Authorization", access_token)
82
+ .header("User-agent", SMB_USER_AGENT);
83
+ request_empty(builder).await
84
+}
crates/cli/src/tenant/url_builder.rs
new
+21
@@ -0,0 +1,21 @@
1
+use {
2
+ smbcloud_network::environment::Environment,
3
+ smbcloud_networking::{smb_base_url_builder, smb_client::SmbClient},
4
+};
5
+
6
+pub(crate) fn build_tenants_url(env: Environment, client: (&SmbClient, &str)) -> String {
7
+ let mut url_builder = smb_base_url_builder(env, client);
8
+ url_builder.add_route("v1/tenants");
9
+ url_builder.build()
10
+}
11
+
12
+pub(crate) fn build_tenant_url(
13
+ env: Environment,
14
+ client: (&SmbClient, &str),
15
+ tenant_id: &str,
16
+) -> String {
17
+ let mut url_builder = smb_base_url_builder(env, client);
18
+ url_builder.add_route("v1/tenants");
19
+ url_builder.add_route(tenant_id);
20
+ url_builder.build()
21
+}
crates/smbcloud-model/src/lib.rs
+1
@@ -13,6 +13,7 @@ pub mod repository;
13
pub mod reset_password_response;
14
pub mod runner;
15
pub mod signup;
16
+pub mod tenant;
17
18
pub mod ar_date_format {
19
use chrono::{DateTime, Utc};
crates/smbcloud-model/src/tenant.rs
new
+123
@@ -0,0 +1,123 @@
1
+use crate::ar_date_format;
2
+use chrono::{DateTime, Utc};
3
+use serde::{Deserialize, Serialize};
4
+use serde_repr::{Deserialize_repr, Serialize_repr};
5
+use std::fmt::Display;
6
+use tsync::tsync;
7
+
8
+/// Personal — bootstrapped on signup, one per user, never created/deleted via
9
+/// the API. Organization — a team workspace created explicitly.
10
+#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq, Default)]
11
+#[repr(u8)]
12
+#[tsync]
13
+pub enum TenantKind {
14
+ #[default]
15
+ Personal = 0,
16
+ Organization = 1,
17
+}
18
+
19
+impl Display for TenantKind {
20
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21
+ match self {
22
+ TenantKind::Personal => write!(f, "personal"),
23
+ TenantKind::Organization => write!(f, "organization"),
24
+ }
25
+ }
26
+}
27
+
28
+/// The current user's role within a tenant, as returned alongside the tenant
29
+/// in list/show responses (kept off the bare `Tenant` record itself).
30
+#[derive(Deserialize_repr, Serialize_repr, Debug, Clone, Copy, PartialEq, Eq)]
31
+#[repr(u8)]
32
+#[tsync]
33
+pub enum TenantRole {
34
+ Owner = 0,
35
+ Admin = 1,
36
+ Member = 2,
37
+}
38
+
39
+impl Display for TenantRole {
40
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41
+ match self {
42
+ TenantRole::Owner => write!(f, "owner"),
43
+ TenantRole::Admin => write!(f, "admin"),
44
+ TenantRole::Member => write!(f, "member"),
45
+ }
46
+ }
47
+}
48
+
49
+#[derive(Serialize, Deserialize, Debug, Clone)]
50
+#[tsync]
51
+pub struct TenantProject {
52
+ pub id: i32,
53
+ pub name: String,
54
+}
55
+
56
+#[derive(Serialize, Deserialize, Debug, Clone)]
57
+#[tsync]
58
+pub struct Tenant {
59
+ pub id: i64,
60
+ pub name: String,
61
+ pub slug: String,
62
+ pub kind: TenantKind,
63
+ /// The requesting user's role in this tenant.
64
+ pub role: TenantRole,
65
+ pub projects_count: i64,
66
+ pub default_project: Option<TenantProject>,
67
+ /// Whether this is the tenant currently selected for the CLI session.
68
+ #[serde(default)]
69
+ pub current: bool,
70
+ #[serde(with = "ar_date_format")]
71
+ pub created_at: DateTime<Utc>,
72
+}
73
+
74
+impl Display for Tenant {
75
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76
+ write!(f, "ID: {}, Name: {}", self.id, self.name)
77
+ }
78
+}
79
+
80
+/// Payload for creating an organization tenant. Personal tenants are
81
+/// bootstrapped on signup and can't be created through this endpoint.
82
+#[derive(Serialize, Deserialize, Debug, Clone)]
83
+#[tsync]
84
+pub struct TenantCreate {
85
+ pub name: String,
86
+}
87
+
88
+#[derive(Serialize, Deserialize, Debug, Clone, Default)]
89
+#[tsync]
90
+pub struct TenantUpdate {
91
+ #[serde(skip_serializing_if = "Option::is_none")]
92
+ pub name: Option<String>,
93
+}
94
+
95
+impl TenantUpdate {
96
+ pub fn is_empty(&self) -> bool {
97
+ self.name.is_none()
98
+ }
99
+}
100
+
101
+#[cfg(test)]
102
+mod tests {
103
+ use super::*;
104
+ use serde_json::json;
105
+
106
+ #[test]
107
+ fn test_tenant_create() {
108
+ let tenant_create = TenantCreate {
109
+ name: "Acme".to_owned(),
110
+ };
111
+ let json = json!({ "name": "Acme" });
112
+ assert_eq!(serde_json::to_value(tenant_create).unwrap(), json);
113
+ }
114
+
115
+ #[test]
116
+ fn test_tenant_update_is_empty() {
117
+ assert!(TenantUpdate::default().is_empty());
118
+ assert!(!TenantUpdate {
119
+ name: Some("Acme".to_owned())
120
+ }
121
+ .is_empty());
122
+ }
123
+}