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()
}
}
⭐
Log in to mark this repository.
8.7 KiB · text
History
8b74183