⭐
Log in to mark this repository.
Add user-admin CLI for password resets and account management
8b74183 · Gabriel Morell · 2026-08-25 15:49
Message
{commit_body(@commit)}
Files changed
modified
Cargo.lock
+23
−0
@@ -1314,6 +1314,7 @@ dependencies = [
| 1314 | 1314 | "image", |
| 1315 | 1315 | "lapin", |
| 1316 | 1316 | "log", |
| 1317 | + "metrics", | |
| 1317 | 1318 | "metrics-process", |
| 1318 | 1319 | "migration", |
| 1319 | 1320 | "mime", |
@@ -1323,6 +1324,7 @@ dependencies = [
| 1323 | 1324 | "ocrs", |
| 1324 | 1325 | "qrcode", |
| 1325 | 1326 | "reqwest", |
| 1327 | + "rpassword", | |
| 1326 | 1328 | "rten", |
| 1327 | 1329 | "rten-imageproc", |
| 1328 | 1330 | "sea-orm", |
@@ -3727,6 +3729,17 @@ dependencies = [
| 3727 | 3729 | "libc", |
| 3728 | 3730 | ] |
| 3729 | 3731 | |
| 3732 | +[[package]] | |
| 3733 | +name = "rpassword" | |
| 3734 | +version = "7.5.4" | |
| 3735 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3736 | +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" | |
| 3737 | +dependencies = [ | |
| 3738 | + "libc", | |
| 3739 | + "rtoolbox", | |
| 3740 | + "windows-sys 0.61.1", | |
| 3741 | +] | |
| 3742 | + | |
| 3730 | 3743 | [[package]] |
| 3731 | 3744 | name = "rsa" |
| 3732 | 3745 | version = "0.9.8" |
@@ -3823,6 +3836,16 @@ dependencies = [
| 3823 | 3836 | "rten-simd", |
| 3824 | 3837 | ] |
| 3825 | 3838 | |
| 3839 | +[[package]] | |
| 3840 | +name = "rtoolbox" | |
| 3841 | +version = "0.0.5" | |
| 3842 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3843 | +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" | |
| 3844 | +dependencies = [ | |
| 3845 | + "libc", | |
| 3846 | + "windows-sys 0.59.0", | |
| 3847 | +] | |
| 3848 | + | |
| 3826 | 3849 | [[package]] |
| 3827 | 3850 | name = "rust_decimal" |
| 3828 | 3851 | version = "1.38.0" |
modified
Dockerfile
+4
−0
@@ -52,10 +52,12 @@ RUN cargo build --release \
| 52 | 52 | --bin webservice \ |
| 53 | 53 | --bin queue-worker \ |
| 54 | 54 | --bin registration-token \ |
| 55 | + --bin user-admin \ | |
| 55 | 56 | && mkdir -p /out \ |
| 56 | 57 | && cp target/release/webservice /out/ \ |
| 57 | 58 | && cp target/release/queue-worker /out/ \ |
| 58 | 59 | && cp target/release/registration-token /out/ \ |
| 60 | + && cp target/release/user-admin /out/ \ | |
| 59 | 61 | && rm -rf target |
| 60 | 62 | |
| 61 | 63 | # --------------------------------------------------------------------------- |
@@ -87,6 +89,7 @@ WORKDIR /app
| 87 | 89 | COPY --from=rust-builder /out/webservice /usr/local/bin/dms-webservice |
| 88 | 90 | COPY --from=rust-builder /out/queue-worker /usr/local/bin/dms-queue-worker |
| 89 | 91 | COPY --from=rust-builder /out/registration-token /usr/local/bin/dms-registration-token |
| 92 | +COPY --from=rust-builder /out/user-admin /usr/local/bin/dms-user-admin | |
| 90 | 93 | COPY --from=assets /app/assets/f /app/assets/f |
| 91 | 94 | COPY --chown=dms:dms templates /app/templates |
| 92 | 95 | # OCR models bundled at a fixed image path — read-only and tied to the app |
@@ -110,4 +113,5 @@ VOLUME ["/data"]
| 110 | 113 | # `docker run … dms-webservice` → web (default) |
| 111 | 114 | # `docker run … dms-queue-worker --kind ocr` → categorizer |
| 112 | 115 | # `docker run … dms-registration-token …` → CLI |
| 116 | +# `docker run … dms-user-admin …` → user management CLI | |
| 113 | 117 | CMD ["dms-webservice"] |
modified
Justfile
+13
−0
@@ -63,6 +63,14 @@ ocr-models:
| 63 | 63 | invite days="7": |
| 64 | 64 | cargo run --bin registration-token -- create --days {{days}} |
| 65 | 65 | |
| 66 | +# Manage users. Usage: just user list | just user "show alice" | just user "reset-password alice --generate" | |
| 67 | +user *args: | |
| 68 | + cargo run --bin user-admin -- {{args}} | |
| 69 | + | |
| 70 | +# Reset a user's password, prompting for the new one. Usage: just reset-password alice | |
| 71 | +reset-password user: | |
| 72 | + cargo run --bin user-admin -- reset-password {{user}} | |
| 73 | + | |
| 66 | 74 | # Lint + test (unit only) |
| 67 | 75 | check: |
| 68 | 76 | cargo check --bins --lib |
@@ -90,3 +98,8 @@ logs:
| 90 | 98 | # Bootstrap an invite token from inside the running webservice container. |
| 91 | 99 | invite-in-pod days="7": |
| 92 | 100 | podman compose exec webservice dms-registration-token create --days {{days}} |
| 101 | + | |
| 102 | +# Manage users inside the running webservice container. | |
| 103 | +# Usage: just user-in-pod "reset-password alice --generate" | |
| 104 | +user-in-pod *args: | |
| 105 | + podman compose exec webservice dms-user-admin {{args}} |
modified
README.md
+24
−0
@@ -37,6 +37,7 @@ replicas.
| 37 | 37 | | `src/bin/webservice.rs` | HTTP + HTML server (axum + minijinja). | |
| 38 | 38 | | `src/bin/queue-worker.rs` | Categorizer worker. Picks one kind via `WORKER_KIND` env / `--kind`. | |
| 39 | 39 | | `src/bin/registration-token.rs` | CLI for generating invite tokens out of band. | |
| 40 | +| `src/bin/user-admin.rs` | CLI for managing users — password resets, activate/deactivate. | | |
| 40 | 41 | | `src/queue/` | RabbitMQ topology (per-kind queues + DLX), publish/consume, typed `JobMessage`. | |
| 41 | 42 | | `src/storage/` | `Storage` trait + content-addressed `LocalStorage` (sha256 fan-out). | |
| 42 | 43 | | `src/workers/` | Concrete categorizers — thumbnail (image crate), OCR (ocrs), describe (pluggable), extract_tags. | |
@@ -132,6 +133,29 @@ Public registration is off by default. To onboard a user:
| 132 | 133 | 2. Or out-of-band: `just invite` (or `just invite-in-pod` for the |
| 133 | 134 | containerized variant) to mint a token via the CLI. |
| 134 | 135 | |
| 136 | +### Managing existing users | |
| 137 | + | |
| 138 | +There is no self-service password reset, so recovery is a CLI operation. | |
| 139 | +`user-admin` takes a username, email, or UUID interchangeably: | |
| 140 | + | |
| 141 | +```bash | |
| 142 | +just user list # who exists | |
| 143 | +just user "show alice" | |
| 144 | +just user "reset-password alice --generate" # random password, printed once | |
| 145 | +just user "reset-password alice" # prompt for it (hidden) | |
| 146 | +just user "deactivate alice" # revokes sessions too | |
| 147 | +just user "activate alice" | |
| 148 | +``` | |
| 149 | + | |
| 150 | +Resetting a password revokes every active session for that account, so a | |
| 151 | +compromised login is cut off immediately. Prefer `--generate` or the prompt | |
| 152 | +over `--password`: an argument is visible in shell history and `ps`. The | |
| 153 | +password can also be piped in for scripted use — `echo "$PW" | user-admin | |
| 154 | +reset-password alice`. | |
| 155 | + | |
| 156 | +Inside the running container the binary is `dms-user-admin`; `just user-in-pod | |
| 157 | +"reset-password alice --generate"` wraps `podman compose exec`. | |
| 158 | + | |
| 135 | 159 | ## Search |
| 136 | 160 | |
| 137 | 161 | Two surfaces, same engine. |
modified
src/auth/mod.rs
+2
−1
@@ -16,5 +16,6 @@ pub use tokens::{
| 16 | 16 | pub use users::{ |
| 17 | 17 | CreateUserRequest, LoginRequest, |
| 18 | 18 | create_user, authenticate_user, get_user_by_id, update_user, |
| 19 | − change_password, deactivate_user, | |
| 19 | + change_password, reset_password, find_user_by_identifier, list_users, | |
| 20 | + activate_user, deactivate_user, | |
| 20 | 21 | }; |
modified
src/auth/password.rs
+18
−2
@@ -1,11 +1,14 @@
| 1 | 1 | use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; |
| 2 | 2 | use argon2::password_hash::SaltString; |
| 3 | +use argon2::password_hash::rand_core::OsRng; | |
| 3 | 4 | use anyhow::{Context, Result}; |
| 4 | 5 | |
| 5 | 6 | /// Hash a password using Argon2 |
| 6 | 7 | pub fn hash_password(password: &str) -> Result<String> { |
| 7 | − // Use a simple salt for now - in production you'd want proper random salt generation | |
| 8 | − let salt = SaltString::encode_b64(&[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]).map_err(|e| anyhow::anyhow!("Failed to generate salt: {}", e))?; | |
| 8 | + // Fresh random salt per hash. The salt is embedded in the returned PHC | |
| 9 | + // string, so `verify_password` keeps working against older hashes. | |
| 10 | + let salt = SaltString::try_from_rng(&mut OsRng) | |
| 11 | + .map_err(|e| anyhow::anyhow!("Failed to generate salt: {}", e))?; | |
| 9 | 12 | let argon2 = Argon2::default(); |
| 10 | 13 | let password_hash = argon2 |
| 11 | 14 | .hash_password(password.as_bytes(), &salt) |
@@ -40,4 +43,17 @@ mod tests {
| 40 | 43 | // Verify incorrect password |
| 41 | 44 | assert!(!verify_password("wrong_password", &hash).unwrap()); |
| 42 | 45 | } |
| 46 | + | |
| 47 | + #[test] | |
| 48 | + fn test_hashes_are_salted_per_call() { | |
| 49 | + let password = "test_password_123"; | |
| 50 | + let a = hash_password(password).expect("Failed to hash password"); | |
| 51 | + let b = hash_password(password).expect("Failed to hash password"); | |
| 52 | + | |
| 53 | + // Two hashes of the same password must not collide, or the hashes | |
| 54 | + // leak which accounts share a password. | |
| 55 | + assert_ne!(a, b); | |
| 56 | + assert!(verify_password(password, &a).unwrap()); | |
| 57 | + assert!(verify_password(password, &b).unwrap()); | |
| 58 | + } | |
| 43 | 59 | } |
modified
src/auth/users.rs
+85
−0
@@ -208,6 +208,91 @@ pub async fn change_password(
| 208 | 208 | Ok(()) |
| 209 | 209 | } |
| 210 | 210 | |
| 211 | +/// Administratively reset a user's password without knowing the old one. | |
| 212 | +/// | |
| 213 | +/// Unlike [`change_password`] this is for out-of-band recovery (CLI), so it | |
| 214 | +/// revokes every active session to lock out whoever may have had access. | |
| 215 | +/// Returns the number of sessions that were revoked. | |
| 216 | +pub async fn reset_password( | |
| 217 | + db: &DatabaseConnection, | |
| 218 | + user_id: Uuid, | |
| 219 | + new_password: &str, | |
| 220 | +) -> Result<u64> { | |
| 221 | + let user = get_user_by_id(db, user_id) | |
| 222 | + .await? | |
| 223 | + .ok_or_else(|| anyhow::anyhow!("User not found"))?; | |
| 224 | + | |
| 225 | + let new_password_hash = password::hash_password(new_password) | |
| 226 | + .context("Failed to hash new password")?; | |
| 227 | + | |
| 228 | + let mut user_update: users::ActiveModel = user.into(); | |
| 229 | + user_update.password_hash = Set(new_password_hash); | |
| 230 | + user_update.updated_at = Set(Utc::now().into()); | |
| 231 | + | |
| 232 | + user_update | |
| 233 | + .update(db) | |
| 234 | + .await | |
| 235 | + .context("Failed to update password")?; | |
| 236 | + | |
| 237 | + let revoked = user_sessions::Entity::delete_many() | |
| 238 | + .filter(user_sessions::Column::UserId.eq(user_id)) | |
| 239 | + .exec(db) | |
| 240 | + .await | |
| 241 | + .context("Failed to revoke user sessions")?; | |
| 242 | + | |
| 243 | + Ok(revoked.rows_affected) | |
| 244 | +} | |
| 245 | + | |
| 246 | +/// Look a user up by UUID, username, or email — whichever the identifier | |
| 247 | +/// happens to be. Lets admin tooling take whatever the operator has on hand. | |
| 248 | +pub async fn find_user_by_identifier( | |
| 249 | + db: &DatabaseConnection, | |
| 250 | + identifier: &str, | |
| 251 | +) -> Result<Option<users::Model>> { | |
| 252 | + if let Ok(id) = Uuid::parse_str(identifier) { | |
| 253 | + if let Some(user) = get_user_by_id(db, id).await? { | |
| 254 | + return Ok(Some(user)); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + users::Entity::find() | |
| 259 | + .filter( | |
| 260 | + Condition::any() | |
| 261 | + .add(users::Column::Username.eq(identifier)) | |
| 262 | + .add(users::Column::Email.eq(identifier)), | |
| 263 | + ) | |
| 264 | + .one(db) | |
| 265 | + .await | |
| 266 | + .context("Failed to query user") | |
| 267 | +} | |
| 268 | + | |
| 269 | +/// List all users, newest first. | |
| 270 | +pub async fn list_users(db: &DatabaseConnection) -> Result<Vec<users::Model>> { | |
| 271 | + users::Entity::find() | |
| 272 | + .order_by_desc(users::Column::CreatedAt) | |
| 273 | + .all(db) | |
| 274 | + .await | |
| 275 | + .context("Failed to list users") | |
| 276 | +} | |
| 277 | + | |
| 278 | +/// Reactivate a previously deactivated account | |
| 279 | +pub async fn activate_user(db: &DatabaseConnection, user_id: Uuid) -> Result<()> { | |
| 280 | + let user = get_user_by_id(db, user_id) | |
| 281 | + .await? | |
| 282 | + .ok_or_else(|| anyhow::anyhow!("User not found"))?; | |
| 283 | + | |
| 284 | + let mut user_update: users::ActiveModel = user.into(); | |
| 285 | + user_update.is_active = Set(true); | |
| 286 | + user_update.updated_at = Set(Utc::now().into()); | |
| 287 | + | |
| 288 | + user_update | |
| 289 | + .update(db) | |
| 290 | + .await | |
| 291 | + .context("Failed to activate user")?; | |
| 292 | + | |
| 293 | + Ok(()) | |
| 294 | +} | |
| 295 | + | |
| 211 | 296 | /// Deactivate user account |
| 212 | 297 | pub async fn deactivate_user(db: &DatabaseConnection, user_id: Uuid) -> Result<()> { |
| 213 | 298 | let user = get_user_by_id(db, user_id) |
added
src/bin/user-admin.rs
+247
−0
@@ -0,0 +1,247 @@
| 1 | +use clap::{Parser, Subcommand}; | |
| 2 | +use dms::{ | |
| 3 | + auth, | |
| 4 | + config::{Config, create_db_connection}, | |
| 5 | + entities::users, | |
| 6 | +}; | |
| 7 | +use sea_orm::DatabaseConnection; | |
| 8 | +use std::sync::Arc; | |
| 9 | + | |
| 10 | +#[derive(Parser)] | |
| 11 | +#[command(author, version, about, long_about = None)] | |
| 12 | +#[command(name = "user-admin")] | |
| 13 | +#[command(about = "Manage DMS user accounts from the command line")] | |
| 14 | +struct Cli { | |
| 15 | + #[command(subcommand)] | |
| 16 | + command: Commands, | |
| 17 | +} | |
| 18 | + | |
| 19 | +#[derive(Subcommand)] | |
| 20 | +enum Commands { | |
| 21 | + /// List all users | |
| 22 | + List, | |
| 23 | + /// Show detailed information about a user | |
| 24 | + Show { | |
| 25 | + /// Username, email, or user UUID | |
| 26 | + user: String, | |
| 27 | + }, | |
| 28 | + /// Reset a user's password and revoke their sessions | |
| 29 | + ResetPassword { | |
| 30 | + /// Username, email, or user UUID | |
| 31 | + user: String, | |
| 32 | + /// New password. Prefer --generate or the interactive prompt: an | |
| 33 | + /// argument lands in your shell history and the process table. | |
| 34 | + #[arg(short, long, conflicts_with = "generate")] | |
| 35 | + password: Option<String>, | |
| 36 | + /// Generate a random password and print it | |
| 37 | + #[arg(short, long)] | |
| 38 | + generate: bool, | |
| 39 | + }, | |
| 40 | + /// Reactivate a deactivated account | |
| 41 | + Activate { | |
| 42 | + /// Username, email, or user UUID | |
| 43 | + user: String, | |
| 44 | + }, | |
| 45 | + /// Deactivate an account and revoke its sessions | |
| 46 | + Deactivate { | |
| 47 | + /// Username, email, or user UUID | |
| 48 | + user: String, | |
| 49 | + }, | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// Minimum length for an operator-supplied password. Deliberately modest — | |
| 53 | +/// this is a recovery tool, not the registration form. | |
| 54 | +const MIN_PASSWORD_LEN: usize = 8; | |
| 55 | + | |
| 56 | +#[tokio::main] | |
| 57 | +async fn main() -> Result<(), Box<dyn std::error::Error>> { | |
| 58 | + tracing_subscriber::fmt::init(); | |
| 59 | + | |
| 60 | + let cli = Cli::parse(); | |
| 61 | + | |
| 62 | + let config = Config::from_env()?; | |
| 63 | + let db = Arc::new(create_db_connection(&config.database_url).await?); | |
| 64 | + | |
| 65 | + match cli.command { | |
| 66 | + Commands::List => list_users(&db).await?, | |
| 67 | + Commands::Show { user } => show_user(&db, &user).await?, | |
| 68 | + Commands::ResetPassword { | |
| 69 | + user, | |
| 70 | + password, | |
| 71 | + generate, | |
| 72 | + } => reset_password(&db, &user, password, generate).await?, | |
| 73 | + Commands::Activate { user } => set_active(&db, &user, true).await?, | |
| 74 | + Commands::Deactivate { user } => set_active(&db, &user, false).await?, | |
| 75 | + } | |
| 76 | + | |
| 77 | + Ok(()) | |
| 78 | +} | |
| 79 | + | |
| 80 | +/// Resolve an identifier to a user, or fail with a message naming what was | |
| 81 | +/// looked up so the operator can spot a typo. | |
| 82 | +async fn resolve( | |
| 83 | + db: &DatabaseConnection, | |
| 84 | + identifier: &str, | |
| 85 | +) -> Result<users::Model, Box<dyn std::error::Error>> { | |
| 86 | + auth::find_user_by_identifier(db, identifier) | |
| 87 | + .await? | |
| 88 | + .ok_or_else(|| format!("No user matching '{identifier}' (tried UUID, username, email)").into()) | |
| 89 | +} | |
| 90 | + | |
| 91 | +async fn list_users(db: &DatabaseConnection) -> Result<(), Box<dyn std::error::Error>> { | |
| 92 | + let users = auth::list_users(db).await?; | |
| 93 | + | |
| 94 | + if users.is_empty() { | |
| 95 | + println!("No users found."); | |
| 96 | + return Ok(()); | |
| 97 | + } | |
| 98 | + | |
| 99 | + println!("Users:"); | |
| 100 | + println!("┌──────────────────────────────────────┬──────────────────┬────────────────────────────┬──────────┬─────────────────────┐"); | |
| 101 | + println!("│ ID │ Username │ Email │ Status │ Last login │"); | |
| 102 | + println!("├──────────────────────────────────────┼──────────────────┼────────────────────────────┼──────────┼─────────────────────┤"); | |
| 103 | + | |
| 104 | + for user in users { | |
| 105 | + let last_login = user | |
| 106 | + .last_login_at | |
| 107 | + .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()) | |
| 108 | + .unwrap_or_else(|| "Never".to_string()); | |
| 109 | + | |
| 110 | + println!( | |
| 111 | + "│ {:<36} │ {:<16} │ {:<26} │ {:<8} │ {:<19} │", | |
| 112 | + user.id, | |
| 113 | + truncate(&user.username, 16), | |
| 114 | + truncate(&user.email, 26), | |
| 115 | + if user.is_active { "Active" } else { "Inactive" }, | |
| 116 | + last_login | |
| 117 | + ); | |
| 118 | + } | |
| 119 | + | |
| 120 | + println!("└──────────────────────────────────────┴──────────────────┴────────────────────────────┴──────────┴─────────────────────┘"); | |
| 121 | + | |
| 122 | + Ok(()) | |
| 123 | +} | |
| 124 | + | |
| 125 | +async fn show_user( | |
| 126 | + db: &DatabaseConnection, | |
| 127 | + identifier: &str, | |
| 128 | +) -> Result<(), Box<dyn std::error::Error>> { | |
| 129 | + let user = resolve(db, identifier).await?; | |
| 130 | + | |
| 131 | + println!("User Details:"); | |
| 132 | + println!(); | |
| 133 | + println!("ID: {}", user.id); | |
| 134 | + println!("Username: {}", user.username); | |
| 135 | + println!("Email: {}", user.email); | |
| 136 | + println!("Status: {}", if user.is_active { "Active" } else { "Inactive" }); | |
| 137 | + println!("Created: {}", user.created_at); | |
| 138 | + println!("Updated: {}", user.updated_at); | |
| 139 | + println!( | |
| 140 | + "Last login: {}", | |
| 141 | + user.last_login_at | |
| 142 | + .map(|t| t.to_string()) | |
| 143 | + .unwrap_or_else(|| "Never".to_string()) | |
| 144 | + ); | |
| 145 | + | |
| 146 | + Ok(()) | |
| 147 | +} | |
| 148 | + | |
| 149 | +async fn reset_password( | |
| 150 | + db: &DatabaseConnection, | |
| 151 | + identifier: &str, | |
| 152 | + password: Option<String>, | |
| 153 | + generate: bool, | |
| 154 | +) -> Result<(), Box<dyn std::error::Error>> { | |
| 155 | + let user = resolve(db, identifier).await?; | |
| 156 | + | |
| 157 | + // Resolve the new password before touching the database, so a mistyped | |
| 158 | + // confirmation leaves the account untouched. | |
| 159 | + let (new_password, generated) = match (password, generate) { | |
| 160 | + (_, true) => (generate_password(), true), | |
| 161 | + (Some(password), false) => (password, false), | |
| 162 | + (None, false) => (prompt_password(&user.username)?, false), | |
| 163 | + }; | |
| 164 | + | |
| 165 | + if new_password.len() < MIN_PASSWORD_LEN { | |
| 166 | + return Err(format!("Password must be at least {MIN_PASSWORD_LEN} characters").into()); | |
| 167 | + } | |
| 168 | + | |
| 169 | + let revoked = auth::reset_password(db, user.id, &new_password).await?; | |
| 170 | + | |
| 171 | + println!("✅ Password reset for {} ({})", user.username, user.email); | |
| 172 | + if generated { | |
| 173 | + println!(); | |
| 174 | + println!("Generated password: {new_password}"); | |
| 175 | + println!(" Give this to the user over a secure channel; it is not stored anywhere."); | |
| 176 | + } | |
| 177 | + println!(); | |
| 178 | + println!("Sessions revoked: {revoked}"); | |
| 179 | + if !user.is_active { | |
| 180 | + println!("⚠️ This account is deactivated — the user cannot log in until you run:"); | |
| 181 | + println!(" user-admin activate {}", user.username); | |
| 182 | + } | |
| 183 | + | |
| 184 | + Ok(()) | |
| 185 | +} | |
| 186 | + | |
| 187 | +async fn set_active( | |
| 188 | + db: &DatabaseConnection, | |
| 189 | + identifier: &str, | |
| 190 | + active: bool, | |
| 191 | +) -> Result<(), Box<dyn std::error::Error>> { | |
| 192 | + let user = resolve(db, identifier).await?; | |
| 193 | + | |
| 194 | + if active { | |
| 195 | + auth::activate_user(db, user.id).await?; | |
| 196 | + println!("✅ Activated {} ({})", user.username, user.email); | |
| 197 | + } else { | |
| 198 | + auth::deactivate_user(db, user.id).await?; | |
| 199 | + println!("✅ Deactivated {} ({}); sessions revoked", user.username, user.email); | |
| 200 | + } | |
| 201 | + | |
| 202 | + Ok(()) | |
| 203 | +} | |
| 204 | + | |
| 205 | +/// Read the new password without putting it on the command line. | |
| 206 | +/// | |
| 207 | +/// On a terminal, prompt twice with echo off so a typo can't lock the user | |
| 208 | +/// out. When stdin is a pipe (scripts, `compose exec -T`) take a single line | |
| 209 | +/// from it — there's nothing to echo and no second entry to compare against. | |
| 210 | +fn prompt_password(username: &str) -> Result<String, Box<dyn std::error::Error>> { | |
| 211 | + use std::io::{BufRead, IsTerminal}; | |
| 212 | + | |
| 213 | + if !std::io::stdin().is_terminal() { | |
| 214 | + let mut password = String::new(); | |
| 215 | + std::io::stdin().lock().read_line(&mut password)?; | |
| 216 | + // Strip the line ending the pipe wrote; keep any other whitespace, | |
| 217 | + // which may legitimately be part of the password. | |
| 218 | + let password = password.trim_end_matches(['\r', '\n']).to_string(); | |
| 219 | + if password.is_empty() { | |
| 220 | + return Err("No password on stdin".into()); | |
| 221 | + } | |
| 222 | + return Ok(password); | |
| 223 | + } | |
| 224 | + | |
| 225 | + let first = rpassword::prompt_password(format!("New password for {username}: "))?; | |
| 226 | + let second = rpassword::prompt_password("Confirm password: ")?; | |
| 227 | + | |
| 228 | + if first != second { | |
| 229 | + return Err("Passwords do not match".into()); | |
| 230 | + } | |
| 231 | + | |
| 232 | + Ok(first) | |
| 233 | +} | |
| 234 | + | |
| 235 | +/// 32 hex chars (128 bits) from a v4 UUID — the same source of randomness the | |
| 236 | +/// rest of the codebase uses for tokens. | |
| 237 | +fn generate_password() -> String { | |
| 238 | + uuid::Uuid::new_v4().simple().to_string() | |
| 239 | +} | |
| 240 | + | |
| 241 | +fn truncate(s: &str, width: usize) -> String { | |
| 242 | + if s.chars().count() <= width { | |
| 243 | + s.to_string() | |
| 244 | + } else { | |
| 245 | + s.chars().take(width.saturating_sub(1)).chain(['…']).collect() | |
| 246 | + } | |
| 247 | +} |
Parents: 7ddc104