//! `pic admins` subcommands: `ls`, `create`, `show`, `set`, `rm`. //! //! All endpoints require `Capability::InstanceManageUsers` (owner / //! admin). Member accounts get a 403. use std::io::{IsTerminal, Read}; use anyhow::{anyhow, Context, Result}; use picloud_shared::InstanceRole; use crate::client::{AdminDto, Client, CreateAdminBody, PatchAdminBody}; use crate::config; use crate::output::{KvBlock, OutputMode, Table}; pub async fn ls(mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let admins = client.admins_list().await?; let mut table = Table::new([ "id", "username", "role", "active", "email", "created_at", "last_login_at", ]); for a in admins { table.row([ a.id.to_string(), a.username, a.instance_role.as_str().to_string(), a.is_active.to_string(), a.email.unwrap_or_else(|| "-".into()), a.created_at.to_rfc3339(), a.last_login_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "-".into()), ]); } table.print(mode); Ok(()) } /// Resolve the password the user wants to set: /// * `Some("-")` → read from stdin (no echo when interactive). /// * `Some(other)` → **rejected**. Audit 2026-06-11 (H2) — an inline /// password lands in shell history, `ps aux`, and /// `/proc//cmdline`, readable by any other UID on the host /// while the process runs. /// * `None` → require explicit `--password`. fn resolve_password(password_arg: Option<&str>) -> Result { match password_arg { None => Err(anyhow!( "missing --password; pass --password - to read the password from stdin" )), Some("-") => read_password_from_stdin(), Some(_) => Err(anyhow!( "passing the password inline is not allowed — it leaks into shell history, \ `ps aux`, and /proc//cmdline. Use `--password -` to read it from stdin \ (interactive prompt, or piped: `printf %s \"$PW\" | pic admins create … --password -`)." )), } } fn read_password_from_stdin() -> Result { let stdin = std::io::stdin(); if stdin.is_terminal() { // True no-echo prompt. Audit 2026-06-11 (M2) — the previous // `read_line` path echoed the password despite a "no echo" // claim; `rpassword` is already a dependency via the login flow. rpassword::prompt_password("Password: ").context("read password from stdin") } else { // Piped input — read everything, strip trailing newline. let mut buf = String::new(); stdin .lock() .read_to_string(&mut buf) .context("read password from stdin")?; Ok(buf.trim_end_matches(['\n', '\r']).to_string()) } } pub async fn create( username: &str, password: Option<&str>, role: InstanceRole, email: Option<&str>, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let pw = resolve_password(password)?; if pw.is_empty() { return Err(anyhow!("password must not be empty")); } let body = CreateAdminBody { username, password: &pw, instance_role: role, email, }; let created = client.admins_create(&body).await?; print_admin(&created, mode); Ok(()) } pub async fn show(id: &str, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let a = client.admins_get(id).await?; print_admin(&a, mode); Ok(()) } #[allow(clippy::too_many_arguments)] pub async fn set( id: &str, username: Option<&str>, password: Option<&str>, active: Option, role: Option, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; // Only resolve the password if --password was passed. let pw_owned; let password_ref: Option<&str> = if let Some(raw) = password { pw_owned = resolve_password(Some(raw))?; Some(pw_owned.as_str()) } else { None }; let body = PatchAdminBody { username, password: password_ref, is_active: active, instance_role: role, }; let updated = client.admins_patch(id, &body).await?; print_admin(&updated, mode); Ok(()) } pub async fn rm(id: &str) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; client.admins_delete(id).await?; println!("Deleted admin {id}"); Ok(()) } fn print_admin(a: &AdminDto, mode: OutputMode) { let mut block = KvBlock::new(); block .field("id", a.id.to_string()) .field("username", a.username.clone()) .field("instance_role", a.instance_role.as_str().to_string()) .field("is_active", a.is_active.to_string()) .field("email", a.email.clone().unwrap_or_else(|| "-".into())) .field("created_at", a.created_at.to_rfc3339()) .field( "last_login_at", a.last_login_at .map(|t| t.to_rfc3339()) .unwrap_or_else(|| "-".into()), ); block.print(mode); }