8.7 KiB · text History c20d4f7
use clap::{Parser, Subcommand};
use dms::{
    auth,
    config::{Config, create_db_connection},
    entities::users,
};
use sea_orm::DatabaseConnection;
use std::sync::Arc;

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(name = "user-admin")]
#[command(about = "Manage DMS user accounts from the command line")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// List all users
    List,
    /// Show detailed information about a user
    Show {
        /// Username, email, or user UUID
        user: String,
    },
    /// Reset a user's password and revoke their sessions
    ResetPassword {
        /// Username, email, or user UUID
        user: String,
        /// New password. Prefer --generate or the interactive prompt: an
        /// argument lands in your shell history and the process table.
        #[arg(short, long, conflicts_with = "generate")]
        password: Option<String>,
        /// Generate a random password and print it
        #[arg(short, long)]
        generate: bool,
    },
    /// Reactivate a deactivated account
    Activate {
        /// Username, email, or user UUID
        user: String,
    },
    /// Deactivate an account and revoke its sessions
    Deactivate {
        /// Username, email, or user UUID
        user: String,
    },
}

/// Minimum length for an operator-supplied password. Deliberately modest —
/// this is a recovery tool, not the registration form.
const MIN_PASSWORD_LEN: usize = 8;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tracing_subscriber::fmt::init();

    let cli = Cli::parse();

    let config = Config::from_env()?;
    let db = Arc::new(create_db_connection(&config.database_url).await?);

    match cli.command {
        Commands::List => list_users(&db).await?,
        Commands::Show { user } => show_user(&db, &user).await?,
        Commands::ResetPassword {
            user,
            password,
            generate,
        } => reset_password(&db, &user, password, generate).await?,
        Commands::Activate { user } => set_active(&db, &user, true).await?,
        Commands::Deactivate { user } => set_active(&db, &user, false).await?,
    }

    Ok(())
}

/// Resolve an identifier to a user, or fail with a message naming what was
/// looked up so the operator can spot a typo.
async fn resolve(
    db: &DatabaseConnection,
    identifier: &str,
) -> Result<users::Model, Box<dyn std::error::Error>> {
    auth::find_user_by_identifier(db, identifier)
        .await?
        .ok_or_else(|| format!("No user matching '{identifier}' (tried UUID, username, email)").into())
}

async fn list_users(db: &DatabaseConnection) -> Result<(), Box<dyn std::error::Error>> {
    let users = auth::list_users(db).await?;

    if users.is_empty() {
        println!("No users found.");
        return Ok(());
    }

    println!("Users:");
    println!("┌──────────────────────────────────────┬──────────────────┬────────────────────────────┬──────────┬─────────────────────┐");
    println!("│ ID                                   │ Username         │ Email                      │ Status   │ Last login          │");
    println!("├──────────────────────────────────────┼──────────────────┼────────────────────────────┼──────────┼─────────────────────┤");

    for user in users {
        let last_login = user
            .last_login_at
            .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
            .unwrap_or_else(|| "Never".to_string());

        println!(
            "│ {:<36} │ {:<16} │ {:<26} │ {:<8} │ {:<19} │",
            user.id,
            truncate(&user.username, 16),
            truncate(&user.email, 26),
            if user.is_active { "Active" } else { "Inactive" },
            last_login
        );
    }

    println!("└──────────────────────────────────────┴──────────────────┴────────────────────────────┴──────────┴─────────────────────┘");

    Ok(())
}

async fn show_user(
    db: &DatabaseConnection,
    identifier: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let user = resolve(db, identifier).await?;

    println!("User Details:");
    println!();
    println!("ID: {}", user.id);
    println!("Username: {}", user.username);
    println!("Email: {}", user.email);
    println!("Status: {}", if user.is_active { "Active" } else { "Inactive" });
    println!("Created: {}", user.created_at);
    println!("Updated: {}", user.updated_at);
    println!(
        "Last login: {}",
        user.last_login_at
            .map(|t| t.to_string())
            .unwrap_or_else(|| "Never".to_string())
    );

    Ok(())
}

async fn reset_password(
    db: &DatabaseConnection,
    identifier: &str,
    password: Option<String>,
    generate: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let user = resolve(db, identifier).await?;

    // Resolve the new password before touching the database, so a mistyped
    // confirmation leaves the account untouched.
    let (new_password, generated) = match (password, generate) {
        (_, true) => (generate_password(), true),
        (Some(password), false) => (password, false),
        (None, false) => (prompt_password(&user.username)?, false),
    };

    if new_password.len() < MIN_PASSWORD_LEN {
        return Err(format!("Password must be at least {MIN_PASSWORD_LEN} characters").into());
    }

    let revoked = auth::reset_password(db, user.id, &new_password).await?;

    println!("✅ Password reset for {} ({})", user.username, user.email);
    if generated {
        println!();
        println!("Generated password: {new_password}");
        println!("   Give this to the user over a secure channel; it is not stored anywhere.");
    }
    println!();
    println!("Sessions revoked: {revoked}");
    if !user.is_active {
        println!("⚠️  This account is deactivated — the user cannot log in until you run:");
        println!("   user-admin activate {}", user.username);
    }

    Ok(())
}

async fn set_active(
    db: &DatabaseConnection,
    identifier: &str,
    active: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let user = resolve(db, identifier).await?;

    if active {
        auth::activate_user(db, user.id).await?;
        println!("✅ Activated {} ({})", user.username, user.email);
    } else {
        auth::deactivate_user(db, user.id).await?;
        println!("✅ Deactivated {} ({}); sessions revoked", user.username, user.email);
    }

    Ok(())
}

/// Read the new password without putting it on the command line.
///
/// On a terminal, prompt twice with echo off so a typo can't lock the user
/// out. When stdin is a pipe (scripts, `compose exec -T`) take a single line
/// from it — there's nothing to echo and no second entry to compare against.
fn prompt_password(username: &str) -> Result<String, Box<dyn std::error::Error>> {
    use std::io::{BufRead, IsTerminal};

    if !std::io::stdin().is_terminal() {
        let mut password = String::new();
        std::io::stdin().lock().read_line(&mut password)?;
        // Strip the line ending the pipe wrote; keep any other whitespace,
        // which may legitimately be part of the password.
        let password = password.trim_end_matches(['\r', '\n']).to_string();
        if password.is_empty() {
            return Err("No password on stdin".into());
        }
        return Ok(password);
    }

    let first = rpassword::prompt_password(format!("New password for {username}: "))?;
    let second = rpassword::prompt_password("Confirm password: ")?;

    if first != second {
        return Err("Passwords do not match".into());
    }

    Ok(first)
}

/// 32 hex chars (128 bits) from a v4 UUID — the same source of randomness the
/// rest of the codebase uses for tokens.
fn generate_password() -> String {
    uuid::Uuid::new_v4().simple().to_string()
}

fn truncate(s: &str, width: usize) -> String {
    if s.chars().count() <= width {
        s.to_string()
    } else {
        s.chars().take(width.saturating_sub(1)).chain(['…']).collect()
    }
}