Skip to main content

raps_cli/commands/admin/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Account Admin Bulk Management Commands
5//!
6//! Commands for bulk user management across ACC/BIM 360 projects:
7//! - Add users to multiple projects
8//! - Remove users from multiple projects
9//! - Update user roles across projects
10//! - Manage folder-level permissions
11
12mod csv_ops;
13mod folder;
14mod operations;
15mod project;
16mod user;
17
18use std::path::PathBuf;
19
20use anyhow::Result;
21use clap::{Subcommand, ValueEnum};
22use indicatif::{ProgressBar, ProgressStyle};
23
24use raps_admin::{PermissionLevel, ProgressUpdate, ProjectFilter};
25use raps_kernel::auth::AuthClient;
26use raps_kernel::config::Config;
27
28use crate::output::OutputFormat;
29
30/// Account admin bulk management commands
31#[derive(Debug, Subcommand)]
32pub enum AdminCommands {
33    /// Bulk user management operations
34    #[command(subcommand)]
35    User(UserCommands),
36
37    /// Bulk folder permission management
38    #[command(subcommand)]
39    Folder(FolderCommands),
40
41    /// Project listing with filtering
42    #[command(subcommand)]
43    Project(AdminProjectCommands),
44
45    /// Bulk operation management (status, resume, cancel)
46    #[command(subcommand)]
47    Operation(OperationCommands),
48
49    /// List companies in an account
50    #[command(name = "company-list")]
51    CompanyList {
52        /// Account ID (defaults to APS_ACCOUNT_ID env var)
53        #[arg(short, long)]
54        account: Option<String>,
55    },
56}
57
58/// User management subcommands
59#[derive(Debug, Subcommand)]
60pub enum UserCommands {
61    /// List users in an account or project
62    List {
63        /// Account ID (defaults to APS_ACCOUNT_ID env var)
64        #[arg(short, long)]
65        account: Option<String>,
66
67        /// Optional: list users for a specific project only
68        #[arg(short, long)]
69        project: Option<String>,
70
71        /// Filter by role name
72        #[arg(long)]
73        role: Option<String>,
74
75        /// Filter by status (active, inactive, not_invited)
76        #[arg(long)]
77        status: Option<String>,
78
79        /// Search by email or name
80        #[arg(long)]
81        search: Option<String>,
82    },
83
84    /// Add a user to multiple projects
85    Add {
86        /// Email address of the user to add
87        email: String,
88
89        /// Account ID (defaults to current profile account)
90        #[arg(short, long)]
91        account: Option<String>,
92
93        /// Role to assign (e.g., "Project Admin", "Document Manager")
94        #[arg(short, long)]
95        role: Option<String>,
96
97        /// Project filter expression (e.g., "name:*Hospital*,status:active")
98        #[arg(short, long)]
99        filter: Option<String>,
100
101        /// File containing project IDs (one per line)
102        #[arg(long, value_name = "FILE")]
103        project_ids: Option<PathBuf>,
104
105        /// Parallel requests (default: 10, max: 50)
106        #[arg(long, default_value = "10")]
107        concurrency: usize,
108
109        /// Preview changes without executing
110        #[arg(long)]
111        dry_run: bool,
112
113        /// Skip confirmation prompt
114        #[arg(short, long)]
115        yes: bool,
116    },
117
118    /// Remove a user from multiple projects
119    Remove {
120        /// Email address of the user to remove
121        email: String,
122
123        /// Account ID
124        #[arg(short, long)]
125        account: Option<String>,
126
127        /// Project filter expression
128        #[arg(short, long)]
129        filter: Option<String>,
130
131        /// File containing project IDs (one per line)
132        #[arg(long, value_name = "FILE")]
133        project_ids: Option<PathBuf>,
134
135        /// Parallel requests (default: 10, max: 50)
136        #[arg(long, default_value = "10")]
137        concurrency: usize,
138
139        /// Preview changes without executing
140        #[arg(long)]
141        dry_run: bool,
142
143        /// Skip confirmation prompt
144        #[arg(short, long)]
145        yes: bool,
146    },
147
148    /// Update user roles and/or company across multiple projects
149    Update {
150        /// Email address of the user to update
151        email: String,
152
153        /// Account ID
154        #[arg(short, long)]
155        account: Option<String>,
156
157        /// New role to assign (required unless --company is provided)
158        #[arg(short, long)]
159        role: Option<String>,
160
161        /// Company name to assign at account level
162        #[arg(long)]
163        company: Option<String>,
164
165        /// Only update users with this current role
166        #[arg(long)]
167        from_role: Option<String>,
168
169        /// Project filter expression
170        #[arg(short, long)]
171        filter: Option<String>,
172
173        /// File containing project IDs (one per line)
174        #[arg(long, value_name = "FILE")]
175        project_ids: Option<PathBuf>,
176
177        /// Import updates from a CSV file (columns: email, role, company)
178        #[arg(long, value_name = "FILE")]
179        from_csv: Option<PathBuf>,
180
181        /// Parallel requests (default: 10, max: 50)
182        #[arg(long, default_value = "10")]
183        concurrency: usize,
184
185        /// Preview changes without executing
186        #[arg(long)]
187        dry_run: bool,
188
189        /// Skip confirmation prompt
190        #[arg(short, long)]
191        yes: bool,
192    },
193
194    /// Add a user to a single project by email
195    #[command(name = "add-to-project")]
196    AddToProject {
197        /// Project ID
198        #[arg(short, long)]
199        project: String,
200
201        /// Email address of the user (used as user identifier)
202        #[arg(short, long)]
203        email: String,
204
205        /// Role ID to assign
206        #[arg(short, long)]
207        role_id: Option<String>,
208    },
209
210    /// Remove a user from a single project
211    #[command(name = "remove-from-project")]
212    RemoveFromProject {
213        /// Project ID
214        #[arg(short, long)]
215        project: String,
216
217        /// User ID to remove
218        #[arg(short, long)]
219        user_id: String,
220
221        /// Skip confirmation prompt
222        #[arg(short, long)]
223        yes: bool,
224    },
225
226    /// Update a user's role in a single project
227    #[command(name = "update-in-project")]
228    UpdateInProject {
229        /// Project ID
230        #[arg(short, long)]
231        project: String,
232
233        /// User ID to update
234        #[arg(short, long)]
235        user_id: String,
236
237        /// New role ID to assign
238        #[arg(short, long)]
239        role_id: Option<String>,
240    },
241
242    /// Import new users to a project from CSV
243    #[command(name = "import")]
244    Import {
245        /// Project ID to import users into
246        #[arg(short, long)]
247        project: String,
248
249        /// CSV file with columns: email, role_id (optional)
250        #[arg(long, value_name = "FILE")]
251        from_csv: PathBuf,
252    },
253}
254
255/// Folder permission management subcommands
256#[derive(Debug, Subcommand)]
257pub enum FolderCommands {
258    /// Update folder permissions for a user across projects
259    Rights {
260        /// Email address of the user
261        email: String,
262
263        /// Account ID
264        #[arg(short, long)]
265        account: Option<String>,
266
267        /// Permission level (required)
268        #[arg(short, long, value_enum)]
269        level: PermissionLevelArg,
270
271        /// Folder type: project-files, plans, or custom path
272        #[arg(long, default_value = "project-files")]
273        folder: String,
274
275        /// Project filter expression
276        #[arg(short, long)]
277        filter: Option<String>,
278
279        /// File containing project IDs (one per line)
280        #[arg(long, value_name = "FILE")]
281        project_ids: Option<PathBuf>,
282
283        /// Parallel requests (default: 10, max: 50)
284        #[arg(long, default_value = "10")]
285        concurrency: usize,
286
287        /// Preview changes without executing
288        #[arg(long)]
289        dry_run: bool,
290
291        /// Skip confirmation prompt
292        #[arg(short, long)]
293        yes: bool,
294    },
295}
296
297/// Permission level argument for CLI
298#[derive(Debug, Clone, Copy, ValueEnum)]
299pub enum PermissionLevelArg {
300    /// View only access
301    ViewOnly,
302    /// View and download access
303    ViewDownload,
304    /// Upload only access
305    UploadOnly,
306    /// View, download, and upload access
307    ViewDownloadUpload,
308    /// View, download, upload, and edit access
309    ViewDownloadUploadEdit,
310    /// Full folder control
311    FolderControl,
312}
313
314impl From<PermissionLevelArg> for PermissionLevel {
315    fn from(arg: PermissionLevelArg) -> Self {
316        match arg {
317            PermissionLevelArg::ViewOnly => PermissionLevel::ViewOnly,
318            PermissionLevelArg::ViewDownload => PermissionLevel::ViewDownload,
319            PermissionLevelArg::UploadOnly => PermissionLevel::UploadOnly,
320            PermissionLevelArg::ViewDownloadUpload => PermissionLevel::ViewDownloadUpload,
321            PermissionLevelArg::ViewDownloadUploadEdit => PermissionLevel::ViewDownloadUploadEdit,
322            PermissionLevelArg::FolderControl => PermissionLevel::FolderControl,
323        }
324    }
325}
326
327/// Project listing subcommands (for admin context)
328#[derive(Debug, Subcommand)]
329pub enum AdminProjectCommands {
330    /// List projects with filtering
331    List {
332        /// Account ID
333        #[arg(short, long)]
334        account: Option<String>,
335
336        /// Filter expression
337        #[arg(short, long)]
338        filter: Option<String>,
339
340        /// Project status: active, inactive, archived
341        #[arg(long)]
342        status: Option<String>,
343
344        /// Platform: acc, bim360, all (default: all)
345        #[arg(long, default_value = "all")]
346        platform: String,
347
348        /// Maximum projects to return
349        #[arg(long)]
350        limit: Option<usize>,
351    },
352
353    /// Create a new project
354    Create {
355        /// Account ID (defaults to APS_ACCOUNT_ID env var)
356        #[arg(short, long)]
357        account: Option<String>,
358
359        /// Project name
360        #[arg(short, long)]
361        name: String,
362
363        /// Project type
364        #[arg(short = 't', long)]
365        r#type: Option<String>,
366
367        /// Project classification (production, template, component, sample)
368        #[arg(long)]
369        classification: Option<String>,
370
371        /// Project start date (ISO 8601 format)
372        #[arg(long)]
373        start_date: Option<String>,
374
375        /// Project end date (ISO 8601 format)
376        #[arg(long)]
377        end_date: Option<String>,
378
379        /// Time zone (e.g., "America/New_York")
380        #[arg(long)]
381        timezone: Option<String>,
382    },
383
384    /// Update an existing project
385    Update {
386        /// Account ID (defaults to APS_ACCOUNT_ID env var)
387        #[arg(short, long)]
388        account: Option<String>,
389
390        /// Project ID
391        #[arg(short, long)]
392        project: String,
393
394        /// New project name
395        #[arg(short, long)]
396        name: Option<String>,
397
398        /// New project status (active, archived, suspended)
399        #[arg(long)]
400        status: Option<String>,
401
402        /// New start date (ISO 8601 format)
403        #[arg(long)]
404        start_date: Option<String>,
405
406        /// New end date (ISO 8601 format)
407        #[arg(long)]
408        end_date: Option<String>,
409    },
410
411    /// Archive a project (sets status to archived)
412    Archive {
413        /// Account ID (defaults to APS_ACCOUNT_ID env var)
414        #[arg(short, long)]
415        account: Option<String>,
416
417        /// Project ID
418        #[arg(short, long)]
419        project: String,
420    },
421}
422
423/// Operation management subcommands
424#[derive(Debug, Subcommand)]
425pub enum OperationCommands {
426    /// Check status of a bulk operation
427    Status {
428        /// Operation ID (defaults to most recent)
429        operation_id: Option<uuid::Uuid>,
430    },
431
432    /// Resume an interrupted operation
433    Resume {
434        /// Operation ID to resume (defaults to most recent incomplete)
435        operation_id: Option<uuid::Uuid>,
436
437        /// Override concurrency setting
438        #[arg(long)]
439        concurrency: Option<usize>,
440    },
441
442    /// Cancel an in-progress operation
443    Cancel {
444        /// Operation ID to cancel
445        operation_id: Option<uuid::Uuid>,
446
447        /// Skip confirmation prompt
448        #[arg(short, long)]
449        yes: bool,
450    },
451
452    /// List all operations
453    List {
454        /// Filter by status: pending, in_progress, completed, failed, cancelled
455        #[arg(long)]
456        status: Option<String>,
457
458        /// Maximum operations to show
459        #[arg(long, default_value = "10")]
460        limit: usize,
461    },
462}
463
464// ---------------------------------------------------------------------------
465// Shared helpers
466// ---------------------------------------------------------------------------
467
468pub(crate) fn get_account_id(account: Option<String>) -> Result<String> {
469    match account.or_else(|| std::env::var("APS_ACCOUNT_ID").ok()) {
470        Some(id) if !id.is_empty() => Ok(id),
471        _ => {
472            anyhow::bail!(
473                "Account ID is required. Use --account or set APS_ACCOUNT_ID environment variable."
474            );
475        }
476    }
477}
478
479pub(crate) fn parse_filter_with_ids(
480    filter: &Option<String>,
481    project_ids: &Option<PathBuf>,
482) -> Result<ProjectFilter> {
483    let mut project_filter = match filter {
484        Some(f) => ProjectFilter::from_expression(f)?,
485        None => ProjectFilter::new(),
486    };
487    if let Some(ids_file) = project_ids {
488        let content = std::fs::read_to_string(ids_file)?;
489        let ids: Vec<String> = content
490            .lines()
491            .map(|l| l.trim().to_string())
492            .filter(|l| !l.is_empty() && !l.starts_with('#'))
493            .collect();
494        project_filter.include_ids = Some(ids);
495    }
496    Ok(project_filter)
497}
498
499pub(crate) fn create_bulk_progress_bar(output_format: OutputFormat) -> Option<ProgressBar> {
500    if !output_format.supports_colors() {
501        return None;
502    }
503    let pb = ProgressBar::new(0);
504    pb.set_style(
505        ProgressStyle::default_bar()
506            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%) {msg}")
507            .expect("valid progress template")
508            .progress_chars("=>-"),
509    );
510    Some(pb)
511}
512
513pub(crate) fn make_progress_callback(pb: Option<ProgressBar>) -> impl Fn(ProgressUpdate) {
514    move |progress: ProgressUpdate| {
515        if let Some(ref pb) = pb {
516            pb.set_length(progress.total as u64);
517            pb.set_position((progress.completed + progress.failed + progress.skipped) as u64);
518            pb.set_message(format!(
519                "\u{2713}{} \u{25CB}{} \u{2717}{}",
520                progress.completed, progress.skipped, progress.failed
521            ));
522        }
523    }
524}
525
526impl AdminCommands {
527    pub async fn execute(
528        self,
529        config: &Config,
530        auth_client: &AuthClient,
531        output_format: OutputFormat,
532    ) -> Result<()> {
533        match self {
534            AdminCommands::User(cmd) => cmd.execute(config, auth_client, output_format).await,
535            AdminCommands::Folder(cmd) => cmd.execute(config, auth_client, output_format).await,
536            AdminCommands::Project(cmd) => cmd.execute(config, auth_client, output_format).await,
537            AdminCommands::Operation(cmd) => cmd.execute(output_format).await,
538            AdminCommands::CompanyList { account } => {
539                project::execute_company_list(config, auth_client, account, output_format).await
540            }
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::csv_ops::{CsvUpdateErrorOutput, CsvUpdateResultOutput};
548    use super::user::UserListOutput;
549
550    #[test]
551    fn test_csv_update_row_deserialization() {
552        let csv_data = "email,role,company\njohn@example.com,Project Admin,Acme Corp\n";
553        let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
554        let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
555        assert_eq!(row.email, "john@example.com");
556        assert_eq!(row.role.unwrap(), "Project Admin");
557        assert_eq!(row.company.unwrap(), "Acme Corp");
558    }
559
560    #[test]
561    fn test_csv_update_row_minimal() {
562        let csv_data = "email,role,company\njohn@example.com,,\n";
563        let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
564        let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
565        assert_eq!(row.email, "john@example.com");
566        // Empty strings from CSV become Some("") rather than None
567        assert!(
568            row.role.is_none() || row.role.as_deref() == Some(""),
569            "Expected None or empty string for role, got {:?}",
570            row.role
571        );
572        assert!(
573            row.company.is_none() || row.company.as_deref() == Some(""),
574            "Expected None or empty string for company, got {:?}",
575            row.company
576        );
577    }
578
579    #[test]
580    fn test_csv_update_row_email_only_header() {
581        // When only email column is present, optional fields should default
582        let csv_data = "email\njohn@example.com\n";
583        let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
584        let row: super::csv_ops::CsvUpdateRow = rdr.deserialize().next().unwrap().unwrap();
585        assert_eq!(row.email, "john@example.com");
586        assert!(row.role.is_none());
587        assert!(row.company.is_none());
588    }
589
590    #[test]
591    fn test_csv_update_row_multiple_rows() {
592        let csv_data = "\
593email,role,company
594alice@example.com,Project Admin,Alpha Inc
595bob@example.com,Document Manager,Beta LLC
596carol@example.com,,
597";
598        let mut rdr = csv::ReaderBuilder::new().from_reader(csv_data.as_bytes());
599        let rows: Vec<super::csv_ops::CsvUpdateRow> =
600            rdr.deserialize().collect::<Result<Vec<_>, _>>().unwrap();
601        assert_eq!(rows.len(), 3);
602        assert_eq!(rows[0].email, "alice@example.com");
603        assert_eq!(rows[1].email, "bob@example.com");
604        assert_eq!(rows[1].role.as_deref(), Some("Document Manager"));
605        assert_eq!(rows[2].email, "carol@example.com");
606    }
607
608    #[test]
609    fn test_user_list_output_serialization() {
610        let output = UserListOutput {
611            id: "abc-123".to_string(),
612            email: "test@example.com".to_string(),
613            name: "Test User".to_string(),
614            role: "Project Admin".to_string(),
615            company: Some("Acme Corp".to_string()),
616            status: Some("active".to_string()),
617        };
618        let json = serde_json::to_string(&output).unwrap();
619        assert!(json.contains("\"email\":\"test@example.com\""));
620        assert!(json.contains("\"name\":\"Test User\""));
621        assert!(json.contains("\"id\":\"abc-123\""));
622        assert!(json.contains("\"role\":\"Project Admin\""));
623        assert!(json.contains("\"company\":\"Acme Corp\""));
624        assert!(json.contains("\"status\":\"active\""));
625    }
626
627    #[test]
628    fn test_user_list_output_skips_none_fields() {
629        let output = UserListOutput {
630            id: "abc-123".to_string(),
631            email: "test@example.com".to_string(),
632            name: "Test User".to_string(),
633            role: "Admin".to_string(),
634            company: None,
635            status: None,
636        };
637        let json = serde_json::to_string(&output).unwrap();
638        // Fields with skip_serializing_if = "Option::is_none" should be absent
639        assert!(!json.contains("company"));
640        assert!(!json.contains("status"));
641    }
642
643    #[test]
644    fn test_csv_update_result_output_serialization() {
645        let output = CsvUpdateResultOutput {
646            total: 10,
647            updated: 8,
648            skipped: 1,
649            failed: 1,
650            errors: vec![CsvUpdateErrorOutput {
651                email: "fail@test.com".to_string(),
652                error: "not found".to_string(),
653            }],
654        };
655        let json = serde_json::to_string(&output).unwrap();
656        assert!(json.contains("\"total\":10"));
657        assert!(json.contains("\"updated\":8"));
658        assert!(json.contains("\"skipped\":1"));
659        assert!(json.contains("\"failed\":1"));
660        assert!(json.contains("fail@test.com"));
661        assert!(json.contains("not found"));
662    }
663
664    #[test]
665    fn test_csv_update_result_output_empty_errors() {
666        let output = CsvUpdateResultOutput {
667            total: 5,
668            updated: 5,
669            skipped: 0,
670            failed: 0,
671            errors: vec![],
672        };
673        let json = serde_json::to_string(&output).unwrap();
674        assert!(json.contains("\"errors\":[]"));
675    }
676
677    #[test]
678    fn test_csv_update_error_output_serialization() {
679        let output = CsvUpdateErrorOutput {
680            email: "bad@test.com".to_string(),
681            error: "permission denied".to_string(),
682        };
683        let json = serde_json::to_string(&output).unwrap();
684        assert!(json.contains("\"email\":\"bad@test.com\""));
685        assert!(json.contains("\"error\":\"permission denied\""));
686    }
687
688    #[test]
689    fn test_format_project_status_active() {
690        let result = super::project::format_project_status("active");
691        // Should contain the original text (colored output still contains the word)
692        assert!(result.contains("active"));
693    }
694
695    #[test]
696    fn test_format_project_status_unknown() {
697        let result = super::project::format_project_status("pending");
698        assert_eq!(result, "pending");
699    }
700
701    #[test]
702    fn test_format_user_status_active() {
703        let result = super::user::format_user_status("active");
704        assert!(result.contains("active"));
705    }
706
707    #[test]
708    fn test_format_user_status_unknown() {
709        let result = super::user::format_user_status("unknown");
710        assert_eq!(result, "unknown");
711    }
712}