Skip to main content

oauth_db_cli/commands/
user.rs

1use crate::auth;
2use crate::cli::{Cli, UserCommands};
3use crate::client::ApiClient;
4use crate::error::{CliError, Result};
5use crate::output::OutputFormat;
6use colored::Colorize;
7use dialoguer::Confirm;
8use serde::{Deserialize, Serialize};
9
10pub async fn execute(command: &UserCommands, cli: &Cli) -> Result<()> {
11    let client = auth::get_api_client()?;
12    let output_format = cli.output.as_ref().map(|s| OutputFormat::from_str(s)).unwrap_or(OutputFormat::Table);
13    UserCommand::execute(&client, command, output_format).await
14}
15
16pub struct UserCommand;
17
18impl UserCommand {
19    pub async fn execute(
20        client: &ApiClient,
21        command: &UserCommands,
22        format: OutputFormat,
23    ) -> Result<()> {
24        match command {
25            UserCommands::Create {
26                app_id,
27                token,
28                label,
29                storage_quota,
30                max_connections,
31                qps_limit,
32            } => Self::create(client, app_id, token.clone(), label.clone(), *storage_quota, *max_connections, *qps_limit, format).await,
33
34            UserCommands::List {
35                app_id,
36                status,
37                page,
38                page_size,
39            } => Self::list(client, app_id, status.clone(), *page, *page_size, format).await,
40
41            UserCommands::Show { app_id, user_uid } => {
42                Self::show(client, app_id, user_uid, format).await
43            }
44
45            UserCommands::Update {
46                app_id,
47                user_uid,
48                label,
49                storage_quota,
50                max_connections,
51                qps_limit,
52            } => Self::update(client, app_id, user_uid, label.clone(), storage_quota.clone(), max_connections.clone(), qps_limit.clone(), format).await,
53
54            UserCommands::Enable { app_id, user_uid } => {
55                Self::enable(client, app_id, user_uid).await
56            }
57
58            UserCommands::Disable { app_id, user_uid } => {
59                Self::disable(client, app_id, user_uid).await
60            }
61
62            UserCommands::Delete {
63                app_id,
64                user_uid,
65                force,
66            } => Self::delete(client, app_id, user_uid, *force).await,
67
68            UserCommands::ResetToken { app_id, user_uid } => {
69                Self::reset_token(client, app_id, user_uid, format).await
70            }
71
72            UserCommands::Stats { app_id, user_uid } => {
73                Self::stats(client, app_id, user_uid, format).await
74            }
75        }
76    }
77
78    async fn create(
79        client: &ApiClient,
80        app_id: &str,
81        token: Option<String>,
82        label: Option<String>,
83        storage_quota: Option<u32>,
84        max_connections: Option<u32>,
85        qps_limit: Option<u32>,
86        format: OutputFormat,
87    ) -> Result<()> {
88        let mut body = serde_json::json!({});
89
90        if let Some(t) = token {
91            body["token"] = serde_json::json!(t);
92        }
93        if let Some(l) = label {
94            body["label"] = serde_json::json!(l);
95        }
96        if let Some(sq) = storage_quota {
97            body["storage_quota_mb"] = serde_json::json!(sq);
98        }
99        if let Some(mc) = max_connections {
100            body["max_connections"] = serde_json::json!(mc);
101        }
102        if let Some(qps) = qps_limit {
103            body["qps_limit"] = serde_json::json!(qps);
104        }
105
106        let response: CreateUserResponse = client
107            .post(&format!("/api/v1/apps/{}/users", app_id), &body)
108            .await?;
109
110        match format {
111            OutputFormat::Json => {
112                println!("{}", serde_json::to_string_pretty(&response)?);
113            }
114            OutputFormat::Yaml => {
115                println!("{}", serde_yaml::to_string(&response)?);
116            }
117            OutputFormat::Table => {
118                println!("{}", "✓ User created successfully".green().bold());
119                println!("  {}: {}", "User UID".bold(), response.user_uid);
120                println!("  {}: {}", "Token".bold(), response.token.yellow());
121                println!("  {}: {}", "Label".bold(), response.label.as_deref().unwrap_or("(none)"));
122                println!("  {}: {}", "Status".bold(), response.status);
123                println!("  {}: {}", "Created At".bold(), response.created_at);
124                println!();
125                println!("{}", "⚠️  IMPORTANT: Save this token - it won't be shown again!".yellow().bold());
126            }
127        }
128
129        Ok(())
130    }
131
132    async fn list(
133        client: &ApiClient,
134        app_id: &str,
135        status: Option<String>,
136        page: u32,
137        page_size: u32,
138        format: OutputFormat,
139    ) -> Result<()> {
140        let mut query = vec![
141            ("page", page.to_string()),
142            ("page_size", page_size.to_string()),
143        ];
144
145        if let Some(s) = status {
146            query.push(("status", s));
147        }
148
149        let response: ListUsersResponse = client
150            .get(&format!("/api/v1/apps/{}/users", app_id), &query)
151            .await?;
152
153        match format {
154            OutputFormat::Json => {
155                println!("{}", serde_json::to_string_pretty(&response)?);
156            }
157            OutputFormat::Yaml => {
158                println!("{}", serde_yaml::to_string(&response)?);
159            }
160            OutputFormat::Table => {
161                if response.items.is_empty() {
162                    println!("{}", "No users found".yellow());
163                    return Ok(());
164                }
165
166                let mut table = comfy_table::Table::new();
167                table.set_header(vec![
168                    "USER UID",
169                    "LABEL",
170                    "STATUS",
171                    "LAST ACCESSED",
172                    "CREATED AT",
173                ]);
174
175                for user in &response.items {
176                    table.add_row(vec![
177                        user.user_uid.clone(),
178                        user.label.clone().unwrap_or_else(|| "(none)".to_string()),
179                        user.status.clone(),
180                        user.last_accessed_at
181                            .clone()
182                            .unwrap_or_else(|| "Never".to_string()),
183                        user.created_at.clone(),
184                    ]);
185                }
186
187                println!("{}", table);
188                println!(
189                    "\nShowing {} of {} users (page {}/{})",
190                    response.items.len(),
191                    response.total,
192                    page,
193                    (response.total + page_size - 1) / page_size
194                );
195            }
196        }
197
198        Ok(())
199    }
200
201    async fn show(
202        client: &ApiClient,
203        app_id: &str,
204        user_uid: &str,
205        format: OutputFormat,
206    ) -> Result<()> {
207        let response: UserDetail = client
208            .get(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &[])
209            .await?;
210
211        match format {
212            OutputFormat::Json => {
213                println!("{}", serde_json::to_string_pretty(&response)?);
214            }
215            OutputFormat::Yaml => {
216                println!("{}", serde_yaml::to_string(&response)?);
217            }
218            OutputFormat::Table => {
219                println!("{}", format!("User: {}", response.user_uid).bold());
220                println!();
221
222                println!("{}", "Basic Information:".bold());
223                println!("  Label: {}", response.label.as_deref().unwrap_or("(none)"));
224                println!("  Status: {}", response.status);
225                println!();
226
227                println!("{}", "Quotas:".bold());
228                println!("  Storage Quota: {} MB (effective: {} MB)",
229                    response.storage_quota_mb.map(|v| v.to_string()).unwrap_or_else(|| "inherited".to_string()),
230                    response.effective_quotas.storage_quota_mb
231                );
232                println!("  Max Connections: {} (effective: {})",
233                    response.max_connections.map(|v| v.to_string()).unwrap_or_else(|| "inherited".to_string()),
234                    response.effective_quotas.max_connections
235                );
236                println!("  QPS Limit: {} (effective: {})",
237                    response.qps_limit.map(|v| v.to_string()).unwrap_or_else(|| "inherited".to_string()),
238                    response.effective_quotas.qps_limit
239                );
240                println!();
241
242                println!("{}", "Usage:".bold());
243                println!("  Storage Used: {:.2} MB", response.usage.storage_used_mb);
244                println!("  Active Connections: {}", response.usage.active_connections);
245                println!("  Databases: {}", response.usage.databases.join(", "));
246                println!();
247
248                println!("{}", "Timestamps:".bold());
249                println!("  Last Accessed: {}", response.last_accessed_at.as_deref().unwrap_or("Never"));
250                println!("  Created: {}", response.created_at);
251                println!("  Updated: {}", response.updated_at);
252            }
253        }
254
255        Ok(())
256    }
257
258    async fn update(
259        client: &ApiClient,
260        app_id: &str,
261        user_uid: &str,
262        label: Option<String>,
263        storage_quota: Option<String>,
264        max_connections: Option<String>,
265        qps_limit: Option<String>,
266        format: OutputFormat,
267    ) -> Result<()> {
268        let mut body = serde_json::json!({});
269
270        if let Some(l) = label {
271            body["label"] = serde_json::json!(l);
272        }
273
274        // Handle "null" string to set quota to null (inherit from app)
275        if let Some(sq) = storage_quota {
276            if sq == "null" {
277                body["storage_quota_mb"] = serde_json::Value::Null;
278            } else {
279                let val: u32 = sq.parse()
280                    .map_err(|_| CliError::InvalidInput("storage_quota must be a number or 'null'".to_string()))?;
281                body["storage_quota_mb"] = serde_json::json!(val);
282            }
283        }
284
285        if let Some(mc) = max_connections {
286            if mc == "null" {
287                body["max_connections"] = serde_json::Value::Null;
288            } else {
289                let val: u32 = mc.parse()
290                    .map_err(|_| CliError::InvalidInput("max_connections must be a number or 'null'".to_string()))?;
291                body["max_connections"] = serde_json::json!(val);
292            }
293        }
294
295        if let Some(qps) = qps_limit {
296            if qps == "null" {
297                body["qps_limit"] = serde_json::Value::Null;
298            } else {
299                let val: u32 = qps.parse()
300                    .map_err(|_| CliError::InvalidInput("qps_limit must be a number or 'null'".to_string()))?;
301                body["qps_limit"] = serde_json::json!(val);
302            }
303        }
304
305        let response: UpdateUserResponse = client
306            .patch(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &body)
307            .await?;
308
309        match format {
310            OutputFormat::Json => {
311                println!("{}", serde_json::to_string_pretty(&response)?);
312            }
313            OutputFormat::Yaml => {
314                println!("{}", serde_yaml::to_string(&response)?);
315            }
316            OutputFormat::Table => {
317                println!("{}", "✓ User updated successfully".green().bold());
318                println!("  {}: {}", "User UID".bold(), response.user_uid);
319                println!("  {}: {}", "Updated Fields".bold(), response.updated_fields.join(", "));
320                println!("  {}: {}", "Updated At".bold(), response.updated_at);
321            }
322        }
323
324        Ok(())
325    }
326
327    async fn enable(client: &ApiClient, app_id: &str, user_uid: &str) -> Result<()> {
328        let body = serde_json::json!({ "status": "active" });
329        let _: UpdateUserResponse = client
330            .patch(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &body)
331            .await?;
332
333        println!("{}", format!("✓ User {} enabled successfully", user_uid).green().bold());
334        Ok(())
335    }
336
337    async fn disable(client: &ApiClient, app_id: &str, user_uid: &str) -> Result<()> {
338        let body = serde_json::json!({ "status": "disabled" });
339        let _: UpdateUserResponse = client
340            .patch(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &body)
341            .await?;
342
343        println!("{}", format!("✓ User {} disabled successfully", user_uid).green().bold());
344        Ok(())
345    }
346
347    async fn delete(
348        client: &ApiClient,
349        app_id: &str,
350        user_uid: &str,
351        force: bool,
352    ) -> Result<()> {
353        if !force {
354            // Get user details for confirmation
355            let user: UserDetail = client
356                .get(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &[])
357                .await?;
358
359            println!("{}", "⚠️  WARNING: This will permanently delete the user and all their data.".yellow().bold());
360            println!("   App ID: {}", app_id);
361            println!("   User UID: {}", user_uid);
362            println!("   Label: {}", user.label.as_deref().unwrap_or("(none)"));
363            println!("   Storage Used: {:.2} MB", user.usage.storage_used_mb);
364            println!();
365
366            let confirmed = Confirm::new()
367                .with_prompt("Are you sure?")
368                .default(false)
369                .interact()
370                .map_err(|e| CliError::InvalidInput(format!("Failed to read input: {}", e)))?;
371
372            if !confirmed {
373                println!("{}", "Cancelled".yellow());
374                return Ok(());
375            }
376        }
377
378        let response: DeleteUserResponse = client
379            .delete(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid))
380            .await?;
381
382        println!("{}", "✓ User deleted successfully".green().bold());
383        println!("  {}: {}", "User UID".bold(), response.user_uid);
384        println!("  {}: {}", "Workspace Cleanup".bold(), response.workspace_cleanup);
385
386        Ok(())
387    }
388
389    async fn reset_token(
390        client: &ApiClient,
391        app_id: &str,
392        user_uid: &str,
393        format: OutputFormat,
394    ) -> Result<()> {
395        // Interactive confirmation
396        println!("{}", "⚠️  WARNING: This will invalidate the current token immediately.".yellow().bold());
397        println!("   All active connections using the old token will be disconnected.");
398        println!();
399
400        let confirmed = Confirm::new()
401            .with_prompt("Are you sure you want to reset the token?")
402            .default(false)
403            .interact()
404            .map_err(|e| CliError::InvalidInput(format!("Failed to read input: {}", e)))?;
405
406        if !confirmed {
407            println!("{}", "Cancelled".yellow());
408            return Ok(());
409        }
410
411        // Generate new token by updating with empty token field (server generates)
412        let body = serde_json::json!({});
413        let response: ResetTokenResponse = client
414            .patch(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &body)
415            .await?;
416
417        match format {
418            OutputFormat::Json => {
419                println!("{}", serde_json::to_string_pretty(&response)?);
420            }
421            OutputFormat::Yaml => {
422                println!("{}", serde_yaml::to_string(&response)?);
423            }
424            OutputFormat::Table => {
425                println!("{}", "✓ Token reset successfully".green().bold());
426                println!("  {}: {}", "User UID".bold(), response.user_uid);
427                if let Some(token) = &response.token {
428                    println!("  {}: {}", "New Token".bold(), token.yellow());
429                    println!();
430                    println!("{}", "⚠️  IMPORTANT: Save this token - it won't be shown again!".yellow().bold());
431                }
432                println!("  {}: {}", "Updated At".bold(), response.updated_at);
433            }
434        }
435
436        Ok(())
437    }
438
439    async fn stats(
440        client: &ApiClient,
441        app_id: &str,
442        user_uid: &str,
443        format: OutputFormat,
444    ) -> Result<()> {
445        // Use the show endpoint which includes usage stats
446        let response: UserDetail = client
447            .get(&format!("/api/v1/apps/{}/users/{}", app_id, user_uid), &[])
448            .await?;
449
450        match format {
451            OutputFormat::Json => {
452                let stats = serde_json::json!({
453                    "user_uid": response.user_uid,
454                    "label": response.label,
455                    "status": response.status,
456                    "quotas": response.effective_quotas,
457                    "usage": response.usage,
458                });
459                println!("{}", serde_json::to_string_pretty(&stats)?);
460            }
461            OutputFormat::Yaml => {
462                let stats = serde_json::json!({
463                    "user_uid": response.user_uid,
464                    "label": response.label,
465                    "status": response.status,
466                    "quotas": response.effective_quotas,
467                    "usage": response.usage,
468                });
469                println!("{}", serde_yaml::to_string(&stats)?);
470            }
471            OutputFormat::Table => {
472                println!("{}", format!("User Statistics: {} ({})", response.user_uid, response.label.as_deref().unwrap_or("no label")).bold());
473                println!("{}: {}", "Status".bold(), response.status);
474                println!();
475
476                println!("{}", "Storage:".bold());
477                let storage_pct = (response.usage.storage_used_mb / response.effective_quotas.storage_quota_mb as f64) * 100.0;
478                println!("  Used: {:.2} MB / {} MB ({:.1}%)",
479                    response.usage.storage_used_mb,
480                    response.effective_quotas.storage_quota_mb,
481                    storage_pct
482                );
483
484                // Visual progress bar
485                let bar_width = 40;
486                let filled = ((storage_pct / 100.0) * bar_width as f64) as usize;
487                let bar = format!("[{}{}]",
488                    "█".repeat(filled),
489                    "░".repeat(bar_width - filled)
490                );
491                println!("  {}", if storage_pct > 90.0 { bar.red() } else if storage_pct > 75.0 { bar.yellow() } else { bar.green() });
492                println!();
493
494                println!("{}", "Connections:".bold());
495                println!("  Active: {} / {}",
496                    response.usage.active_connections,
497                    response.effective_quotas.max_connections
498                );
499                println!();
500
501                println!("{}", "Rate Limit:".bold());
502                println!("  QPS Limit: {}", response.effective_quotas.qps_limit);
503                println!();
504
505                println!("{}", "Databases:".bold());
506                if response.usage.databases.is_empty() {
507                    println!("  (none created yet)");
508                } else {
509                    for db in &response.usage.databases {
510                        println!("  • {}", db);
511                    }
512                }
513                println!();
514
515                println!("{}", "Activity:".bold());
516                println!("  Last Accessed: {}", response.last_accessed_at.as_deref().unwrap_or("Never"));
517            }
518        }
519
520        Ok(())
521    }
522}
523
524// Response types
525#[derive(Debug, Serialize, Deserialize)]
526struct CreateUserResponse {
527    user_uid: String,
528    token: String,
529    label: Option<String>,
530    status: String,
531    created_at: String,
532}
533
534#[derive(Debug, Serialize, Deserialize)]
535struct ListUsersResponse {
536    items: Vec<UserListItem>,
537    total: u32,
538    page: u32,
539    page_size: u32,
540}
541
542#[derive(Debug, Serialize, Deserialize)]
543struct UserListItem {
544    user_uid: String,
545    label: Option<String>,
546    status: String,
547    last_accessed_at: Option<String>,
548    created_at: String,
549}
550
551#[derive(Debug, Serialize, Deserialize)]
552struct UserDetail {
553    user_uid: String,
554    label: Option<String>,
555    status: String,
556    storage_quota_mb: Option<u32>,
557    max_connections: Option<u32>,
558    qps_limit: Option<u32>,
559    effective_quotas: EffectiveQuotas,
560    usage: Usage,
561    last_accessed_at: Option<String>,
562    created_at: String,
563    updated_at: String,
564}
565
566#[derive(Debug, Serialize, Deserialize)]
567struct EffectiveQuotas {
568    storage_quota_mb: u32,
569    max_connections: u32,
570    qps_limit: u32,
571}
572
573#[derive(Debug, Serialize, Deserialize)]
574struct Usage {
575    storage_used_mb: f64,
576    active_connections: u32,
577    databases: Vec<String>,
578}
579
580#[derive(Debug, Serialize, Deserialize)]
581struct UpdateUserResponse {
582    user_uid: String,
583    updated_fields: Vec<String>,
584    updated_at: String,
585}
586
587#[derive(Debug, Serialize, Deserialize)]
588struct DeleteUserResponse {
589    user_uid: String,
590    deleted: bool,
591    workspace_cleanup: String,
592}
593
594#[derive(Debug, Serialize, Deserialize)]
595struct ResetTokenResponse {
596    user_uid: String,
597    token: Option<String>,
598    updated_at: String,
599}