Skip to main content

raps_cli/commands/admin/
user.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! User management command implementations
5
6use std::sync::Arc;
7
8use anyhow::Result;
9use colored::Colorize;
10use serde::Serialize;
11
12use raps_acc::admin::AccountAdminClient;
13use raps_acc::users::ProjectUsersClient;
14use raps_admin::{BulkConfig, bulk_add_user};
15use raps_kernel::auth::AuthClient;
16use raps_kernel::config::Config;
17use raps_kernel::http::HttpClientConfig;
18
19use crate::output::OutputFormat;
20
21use super::csv_ops::{execute_csv_import, execute_csv_update};
22use super::operations::display_bulk_result;
23use super::{
24    UserCommands, create_bulk_progress_bar, get_account_id, make_progress_callback,
25    parse_filter_with_ids,
26};
27
28#[derive(Serialize)]
29pub(crate) struct UserListOutput {
30    pub(crate) id: String,
31    pub(crate) email: String,
32    pub(crate) name: String,
33    pub(crate) role: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub(crate) company: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub(crate) status: Option<String>,
38}
39
40pub(crate) fn display_user_list(
41    users: &Vec<UserListOutput>,
42    output_format: OutputFormat,
43) -> Result<()> {
44    if users.is_empty() {
45        match output_format {
46            OutputFormat::Table => println!("{}", "No users found.".yellow()),
47            _ => output_format.write(&Vec::<UserListOutput>::new())?,
48        }
49        return Ok(());
50    }
51
52    match output_format {
53        OutputFormat::Table => {
54            println!("{}", "Users:".bold());
55            println!("{}", "\u{2500}".repeat(110));
56            println!(
57                "{:<30} {:<25} {:<18} {:<18} {}",
58                "Email".bold(),
59                "Name".bold(),
60                "Role".bold(),
61                "Status".bold(),
62                "Company".bold()
63            );
64            println!("{}", "\u{2500}".repeat(110));
65
66            for u in users {
67                let email_truncated = if u.email.len() > 28 {
68                    format!("{}...", &u.email[..25])
69                } else {
70                    u.email.clone()
71                };
72                let name_truncated = if u.name.len() > 23 {
73                    format!("{}...", &u.name[..20])
74                } else {
75                    u.name.clone()
76                };
77                let role_display = if u.role.is_empty() {
78                    "-".to_string()
79                } else if u.role.len() > 16 {
80                    format!("{}...", &u.role[..13])
81                } else {
82                    u.role.clone()
83                };
84                let status_display = u.status.as_deref().unwrap_or("-");
85                let company_display = u.company.as_deref().unwrap_or("-");
86
87                println!(
88                    "{:<30} {:<25} {:<18} {:<18} {}",
89                    email_truncated.cyan(),
90                    name_truncated,
91                    role_display,
92                    format_user_status(status_display),
93                    company_display.dimmed()
94                );
95            }
96
97            println!("{}", "\u{2500}".repeat(110));
98            println!("{} {} user(s) found", "\u{2192}".cyan(), users.len());
99        }
100        _ => {
101            output_format.write(users)?;
102        }
103    }
104
105    Ok(())
106}
107
108pub(crate) fn format_user_status(status: &str) -> String {
109    match status.to_lowercase().as_str() {
110        "active" => status.green().to_string(),
111        "inactive" | "not_invited" => status.yellow().to_string(),
112        "disabled" => status.red().to_string(),
113        _ => status.to_string(),
114    }
115}
116
117impl UserCommands {
118    pub async fn execute(
119        self,
120        config: &Config,
121        auth_client: &AuthClient,
122        output_format: OutputFormat,
123    ) -> Result<()> {
124        match self {
125            UserCommands::List {
126                account,
127                project,
128                role,
129                status,
130                search,
131            } => {
132                let account_id = get_account_id(account)?;
133                let http_config = HttpClientConfig::default();
134
135                if let Some(project_id) = project {
136                    // Project-level user listing
137                    if output_format.supports_colors() {
138                        println!(
139                            "\n{} List users in project {}",
140                            "\u{2192}".cyan(),
141                            project_id.cyan()
142                        );
143                        println!();
144                    }
145
146                    let users_client = ProjectUsersClient::new_with_http_config(
147                        config.clone(),
148                        auth_client.clone(),
149                        http_config,
150                    );
151
152                    let all_users = users_client.list_all_project_users(&project_id).await?;
153
154                    // Apply filters
155                    let filtered: Vec<_> = all_users
156                        .into_iter()
157                        .filter(|u| {
158                            if let Some(ref r) = role {
159                                if let Some(ref role_name) = u.role_name {
160                                    if !role_name.to_lowercase().contains(&r.to_lowercase()) {
161                                        return false;
162                                    }
163                                } else {
164                                    return false;
165                                }
166                            }
167                            if let Some(ref s) = search {
168                                let s_lower = s.to_lowercase();
169                                let email_match = u
170                                    .email
171                                    .as_ref()
172                                    .map(|e| e.to_lowercase().contains(&s_lower))
173                                    .unwrap_or(false);
174                                let name_match = u
175                                    .name
176                                    .as_ref()
177                                    .map(|n| n.to_lowercase().contains(&s_lower))
178                                    .unwrap_or(false);
179                                if !email_match && !name_match {
180                                    return false;
181                                }
182                            }
183                            true
184                        })
185                        .collect();
186
187                    let outputs: Vec<UserListOutput> = filtered
188                        .iter()
189                        .map(|u| UserListOutput {
190                            id: u.id.clone(),
191                            email: u.email.clone().unwrap_or_default(),
192                            name: u.name.clone().unwrap_or_default(),
193                            role: u.role_name.clone().unwrap_or_default(),
194                            company: None,
195                            status: None,
196                        })
197                        .collect();
198
199                    display_user_list(&outputs, output_format)?;
200                } else {
201                    // Account-level user listing
202                    if output_format.supports_colors() {
203                        println!(
204                            "\n{} List users in account {}",
205                            "\u{2192}".cyan(),
206                            account_id.cyan()
207                        );
208                        println!();
209                    }
210
211                    let admin_client = AccountAdminClient::new_with_http_config(
212                        config.clone(),
213                        auth_client.clone(),
214                        http_config,
215                    );
216
217                    let all_users = admin_client.list_all_users(&account_id).await?;
218
219                    // Apply filters
220                    let filtered: Vec<_> = all_users
221                        .into_iter()
222                        .filter(|u| {
223                            if let Some(ref s) = status {
224                                if let Some(ref user_status) = u.status {
225                                    if !user_status.to_lowercase().eq(&s.to_lowercase()) {
226                                        return false;
227                                    }
228                                } else {
229                                    return false;
230                                }
231                            }
232                            if let Some(ref s) = search {
233                                let s_lower = s.to_lowercase();
234                                let email_match = u.email.to_lowercase().contains(&s_lower);
235                                let name_match = u
236                                    .name
237                                    .as_ref()
238                                    .map(|n| n.to_lowercase().contains(&s_lower))
239                                    .unwrap_or(false);
240                                let first_match = u
241                                    .first_name
242                                    .as_ref()
243                                    .map(|n| n.to_lowercase().contains(&s_lower))
244                                    .unwrap_or(false);
245                                let last_match = u
246                                    .last_name
247                                    .as_ref()
248                                    .map(|n| n.to_lowercase().contains(&s_lower))
249                                    .unwrap_or(false);
250                                if !email_match && !name_match && !first_match && !last_match {
251                                    return false;
252                                }
253                            }
254                            true
255                        })
256                        .collect();
257
258                    let outputs: Vec<UserListOutput> = filtered
259                        .iter()
260                        .map(|u| {
261                            let display_name = match (&u.first_name, &u.last_name) {
262                                (Some(f), Some(l)) => format!("{} {}", f, l),
263                                (Some(f), None) => f.clone(),
264                                (None, Some(l)) => l.clone(),
265                                (None, None) => u.name.clone().unwrap_or_default(),
266                            };
267                            UserListOutput {
268                                id: u.id.clone(),
269                                email: u.email.clone(),
270                                name: display_name,
271                                role: String::new(),
272                                company: u.company_id.clone(),
273                                status: u.status.clone(),
274                            }
275                        })
276                        .collect();
277
278                    display_user_list(&outputs, output_format)?;
279                }
280
281                Ok(())
282            }
283
284            UserCommands::Add {
285                email,
286                account,
287                role,
288                filter,
289                project_ids,
290                concurrency,
291                dry_run,
292                yes: _,
293            } => {
294                let account_id = get_account_id(account)?;
295                let project_filter = parse_filter_with_ids(&filter, &project_ids)?;
296
297                let bulk_config = BulkConfig {
298                    concurrency: concurrency.min(50),
299                    dry_run,
300                    ..Default::default()
301                };
302
303                if output_format.supports_colors() {
304                    println!(
305                        "\n{} Bulk add user: {} to account {}",
306                        "\u{2192}".cyan(),
307                        email.green(),
308                        account_id.cyan()
309                    );
310                    if let Some(r) = &role {
311                        println!("  Role: {}", r.yellow());
312                    }
313                    if let Some(f) = &filter {
314                        println!("  Filter: {}", f);
315                    }
316                    println!("  Concurrency: {}", concurrency.min(50));
317                    if dry_run {
318                        println!("  {} Dry-run mode enabled", "\u{26A0}".yellow());
319                    }
320                    println!();
321                }
322
323                // Create API clients
324                let http_config = HttpClientConfig::default();
325                let admin_client = AccountAdminClient::new_with_http_config(
326                    config.clone(),
327                    auth_client.clone(),
328                    http_config.clone(),
329                );
330                let users_client = Arc::new(ProjectUsersClient::new_with_http_config(
331                    config.clone(),
332                    auth_client.clone(),
333                    http_config,
334                ));
335
336                let progress_bar = create_bulk_progress_bar(output_format);
337                let on_progress = make_progress_callback(progress_bar.clone());
338
339                let result = bulk_add_user(
340                    &admin_client,
341                    users_client,
342                    &account_id,
343                    &email,
344                    role.as_deref(),
345                    &project_filter,
346                    bulk_config,
347                    on_progress,
348                )
349                .await?;
350
351                // Finish progress bar
352                if let Some(pb) = progress_bar {
353                    pb.finish_and_clear();
354                }
355
356                // Display results
357                display_bulk_result(&result, output_format)?;
358
359                // Exit with appropriate code
360                if result.failed > 0 {
361                    anyhow::bail!(
362                        "Bulk operation partially failed: {} items failed",
363                        result.failed
364                    );
365                }
366
367                Ok(())
368            }
369
370            UserCommands::Remove {
371                email,
372                account,
373                filter,
374                project_ids,
375                concurrency,
376                dry_run,
377                yes: _,
378            } => {
379                let account_id = get_account_id(account)?;
380                let project_filter = parse_filter_with_ids(&filter, &project_ids)?;
381
382                let bulk_config = BulkConfig {
383                    concurrency: concurrency.min(50),
384                    dry_run,
385                    ..Default::default()
386                };
387
388                if output_format.supports_colors() {
389                    println!(
390                        "\n{} Bulk remove user: {} from account {}",
391                        "\u{2192}".cyan(),
392                        email.red(),
393                        account_id.cyan()
394                    );
395                    if let Some(f) = &filter {
396                        println!("  Filter: {}", f);
397                    }
398                    println!("  Concurrency: {}", concurrency.min(50));
399                    if dry_run {
400                        println!("  {} Dry-run mode enabled", "\u{26A0}".yellow());
401                    }
402                    println!();
403                }
404
405                // Create API clients
406                let http_config = HttpClientConfig::default();
407                let admin_client = AccountAdminClient::new_with_http_config(
408                    config.clone(),
409                    auth_client.clone(),
410                    http_config.clone(),
411                );
412                let users_client = Arc::new(ProjectUsersClient::new_with_http_config(
413                    config.clone(),
414                    auth_client.clone(),
415                    http_config,
416                ));
417
418                let progress_bar = create_bulk_progress_bar(output_format);
419                let on_progress = make_progress_callback(progress_bar.clone());
420
421                let result = raps_admin::bulk_remove_user(
422                    &admin_client,
423                    users_client,
424                    &account_id,
425                    &email,
426                    &project_filter,
427                    bulk_config,
428                    on_progress,
429                )
430                .await?;
431
432                // Finish progress bar
433                if let Some(pb) = progress_bar {
434                    pb.finish_and_clear();
435                }
436
437                // Display results
438                display_bulk_result(&result, output_format)?;
439
440                // Exit with appropriate code
441                if result.failed > 0 {
442                    anyhow::bail!(
443                        "Bulk operation partially failed: {} items failed",
444                        result.failed
445                    );
446                }
447
448                Ok(())
449            }
450
451            UserCommands::Update {
452                email,
453                account,
454                role,
455                company,
456                from_role,
457                filter,
458                project_ids,
459                from_csv,
460                concurrency,
461                dry_run,
462                yes: _,
463            } => {
464                // Handle --from-csv mode
465                if let Some(csv_path) = from_csv {
466                    return execute_csv_update(
467                        config,
468                        auth_client,
469                        account.clone(),
470                        filter.clone(),
471                        project_ids.clone(),
472                        &csv_path,
473                        concurrency,
474                        dry_run,
475                        output_format,
476                    )
477                    .await;
478                }
479
480                // Validate: at least --role or --company must be provided
481                if role.is_none() && company.is_none() {
482                    anyhow::bail!("At least one of --role or --company must be provided.");
483                }
484
485                let account_id = get_account_id(account)?;
486
487                let http_config = HttpClientConfig::default();
488                let admin_client = AccountAdminClient::new_with_http_config(
489                    config.clone(),
490                    auth_client.clone(),
491                    http_config.clone(),
492                );
493
494                // Handle company update at account level
495                if let Some(ref company_name) = company {
496                    if output_format.supports_colors() {
497                        println!(
498                            "\n{} Update company for user: {} to: {}",
499                            "\u{2192}".cyan(),
500                            email.green(),
501                            company_name.yellow()
502                        );
503                        if dry_run {
504                            println!("  {} Dry-run mode enabled", "\u{26A0}".yellow());
505                        }
506                    }
507
508                    if !dry_run {
509                        // Look up user by email to get user ID
510                        let user = admin_client
511                            .find_user_by_email(&account_id, &email)
512                            .await?
513                            .ok_or_else(|| anyhow::anyhow!("User not found: {}", email))?;
514
515                        let update_req = raps_acc::admin::UpdateAccountUserRequest {
516                            company_id: None,
517                            company_name: Some(company_name.clone()),
518                        };
519
520                        admin_client
521                            .update_user(&account_id, &user.id, update_req)
522                            .await?;
523
524                        if output_format.supports_colors() {
525                            println!(
526                                "{} Company updated for {} to '{}'",
527                                "\u{2713}".green().bold(),
528                                email,
529                                company_name
530                            );
531                        }
532                    } else if output_format.supports_colors() {
533                        println!(
534                            "  {} Would update company for {} to '{}'",
535                            "\u{2192}".dimmed(),
536                            email,
537                            company_name
538                        );
539                    }
540                }
541
542                // Handle role update across projects (if --role is provided)
543                if let Some(ref role_value) = role {
544                    let project_filter = parse_filter_with_ids(&filter, &project_ids)?;
545
546                    let bulk_config = BulkConfig {
547                        concurrency: concurrency.min(50),
548                        dry_run,
549                        ..Default::default()
550                    };
551
552                    if output_format.supports_colors() {
553                        println!(
554                            "\n{} Bulk update user: {} to role: {}",
555                            "\u{2192}".cyan(),
556                            email.green(),
557                            role_value.yellow()
558                        );
559                        if let Some(fr) = &from_role {
560                            println!("  From role: {}", fr);
561                        }
562                        if let Some(f) = &filter {
563                            println!("  Filter: {}", f);
564                        }
565                        println!("  Concurrency: {}", concurrency.min(50));
566                        if dry_run {
567                            println!("  {} Dry-run mode enabled", "\u{26A0}".yellow());
568                        }
569                        println!();
570                    }
571
572                    let users_client = Arc::new(ProjectUsersClient::new_with_http_config(
573                        config.clone(),
574                        auth_client.clone(),
575                        http_config,
576                    ));
577
578                    let progress_bar = create_bulk_progress_bar(output_format);
579                    let on_progress = make_progress_callback(progress_bar.clone());
580
581                    let result = raps_admin::bulk_update_role(
582                        &admin_client,
583                        users_client,
584                        &account_id,
585                        &email,
586                        role_value,
587                        from_role.as_deref(),
588                        &project_filter,
589                        bulk_config,
590                        on_progress,
591                    )
592                    .await?;
593
594                    // Finish progress bar
595                    if let Some(pb) = progress_bar {
596                        pb.finish_and_clear();
597                    }
598
599                    // Display results
600                    display_bulk_result(&result, output_format)?;
601
602                    // Exit with appropriate code
603                    if result.failed > 0 {
604                        anyhow::bail!(
605                            "Bulk operation partially failed: {} items failed",
606                            result.failed
607                        );
608                    }
609                }
610
611                Ok(())
612            }
613
614            UserCommands::AddToProject {
615                project,
616                email,
617                role_id,
618            } => {
619                let http_config = HttpClientConfig::default();
620                let users_client = ProjectUsersClient::new_with_http_config(
621                    config.clone(),
622                    auth_client.clone(),
623                    http_config,
624                );
625
626                if output_format.supports_colors() {
627                    println!(
628                        "\n{} Adding user {} to project {}",
629                        "\u{2192}".cyan(),
630                        email.cyan(),
631                        project.cyan()
632                    );
633                }
634
635                let request = raps_acc::users::AddProjectUserRequest {
636                    email: email.clone(),
637                    role_id: role_id.clone(),
638                    products: vec![],
639                };
640
641                let user = users_client.add_user(&project, request).await?;
642
643                #[derive(Serialize)]
644                struct AddResult {
645                    user_id: String,
646                    email: String,
647                    role: Option<String>,
648                    project: String,
649                }
650
651                let result = AddResult {
652                    user_id: user.id,
653                    email: user.email.unwrap_or(email),
654                    role: user.role_name,
655                    project,
656                };
657
658                output_format.write(&result)?;
659
660                if output_format.supports_colors() {
661                    println!("\n{} User added successfully", "\u{2713}".green());
662                }
663
664                Ok(())
665            }
666
667            UserCommands::RemoveFromProject {
668                project,
669                user_id,
670                yes,
671            } => {
672                let http_config = HttpClientConfig::default();
673                let users_client = ProjectUsersClient::new_with_http_config(
674                    config.clone(),
675                    auth_client.clone(),
676                    http_config,
677                );
678
679                if !yes && output_format.supports_colors() {
680                    println!(
681                        "\n{} Remove user {} from project {}?",
682                        "\u{26A0}".yellow(),
683                        user_id.cyan(),
684                        project.cyan()
685                    );
686                    print!("Continue? [y/N] ");
687                    use std::io::{self, Write};
688                    io::stdout().flush()?;
689                    let mut input = String::new();
690                    io::stdin().read_line(&mut input)?;
691                    if !input.trim().eq_ignore_ascii_case("y") {
692                        println!("Cancelled.");
693                        return Ok(());
694                    }
695                }
696
697                users_client.remove_user(&project, &user_id).await?;
698
699                if output_format.supports_colors() {
700                    println!(
701                        "\n{} User {} removed from project {}",
702                        "\u{2713}".green(),
703                        user_id.cyan(),
704                        project.cyan()
705                    );
706                } else {
707                    println!("User {} removed from project {}", user_id, project);
708                }
709
710                Ok(())
711            }
712
713            UserCommands::UpdateInProject {
714                project,
715                user_id,
716                role_id,
717            } => {
718                let http_config = HttpClientConfig::default();
719                let users_client = ProjectUsersClient::new_with_http_config(
720                    config.clone(),
721                    auth_client.clone(),
722                    http_config,
723                );
724
725                if output_format.supports_colors() {
726                    println!(
727                        "\n{} Updating user {} in project {}",
728                        "\u{2192}".cyan(),
729                        user_id.cyan(),
730                        project.cyan()
731                    );
732                }
733
734                let request = raps_acc::users::UpdateProjectUserRequest {
735                    role_id: role_id.clone(),
736                    products: None,
737                };
738
739                let user = users_client
740                    .update_user(&project, &user_id, request)
741                    .await?;
742
743                #[derive(Serialize)]
744                struct UpdateResult {
745                    user_id: String,
746                    email: Option<String>,
747                    role: Option<String>,
748                    project: String,
749                }
750
751                let result = UpdateResult {
752                    user_id: user.id,
753                    email: user.email,
754                    role: user.role_name,
755                    project,
756                };
757
758                output_format.write(&result)?;
759
760                if output_format.supports_colors() {
761                    println!("\n{} User updated successfully", "\u{2713}".green());
762                }
763
764                Ok(())
765            }
766
767            UserCommands::Import { project, from_csv } => {
768                execute_csv_import(config, auth_client, &project, &from_csv, output_format).await
769            }
770        }
771    }
772}