use anyhow::Result;
use owo_colors::OwoColorize;
use crate::api::client::ApiClient;
use crate::output::{self, Format};
use crate::render::table::{Cell, Table};
use crate::render::theme;
use crate::render::time;
fn role_label(role: &str) -> &str {
match role {
"admin" => "administrador",
_ => "miembro",
}
}
fn status_label(status: &str) -> &str {
match status {
"pending" => "pendiente",
"accepted" => "aceptada",
_ => "caducada",
}
}
pub async fn list(client: &ApiClient, format: Format) -> Result<()> {
let workspaces = client.workspaces().await?;
if format.answered_in_json(&workspaces) {
return Ok(());
}
let mut table = Table::new(&["id", "espacio", "slug", "tu papel"]);
for workspace in &workspaces {
table.push(vec![
Cell::styled(workspace.id.clone(), theme::muted()),
Cell::styled(workspace.name.clone(), theme::strong()),
Cell::plain(workspace.slug.clone()),
Cell::plain(
workspace
.role
.as_deref()
.map(role_label)
.unwrap_or("—")
.to_string(),
),
]);
}
table.print_or("Todavía no hay ningún espacio.");
Ok(())
}
fn format_euros(micros: i64) -> String {
let euros = micros as f64 / 1e6;
if micros == 0 {
return "0 €".to_string();
}
if euros.abs() < 0.01 {
return format!("{euros:.4} €");
}
format!("{euros:.2} €")
}
pub async fn credits(client: &ApiClient, reference: Option<&str>, format: Format) -> Result<()> {
let workspace = client.find_workspace(reference).await?;
let credits = client.credits(&workspace.id).await?;
if format.answered_in_json(&credits) {
return Ok(());
}
println!();
println!(" {}", workspace.name.style(theme::strong()));
println!();
println!(
" {} {}",
"saldo ".style(theme::muted()),
format_euros(credits.balance_micros).style(theme::strong()),
);
println!(
" {} {}",
"recargado".style(theme::muted()),
format_euros(credits.added_micros),
);
println!(
" {} {}",
"gastado ".style(theme::muted()),
format_euros(credits.spent_micros),
);
if !credits.may_run_agent {
println!();
println!(
" {}",
"Sin saldo: los agentes de este espacio están parados.".style(theme::warning()),
);
} else if credits.running_low {
println!();
println!(
" {}",
format!(
"Queda el {} % del crédito.",
credits.remaining_percent.unwrap_or(0)
)
.style(theme::warning()),
);
}
if credits.history.is_empty() {
println!();
return Ok(());
}
println!();
let mut table = Table::new(&["cuándo", "concepto", "importe"]);
for entry in &credits.history {
let amount = format!(
"{}{}",
if entry.amount_micros >= 0 { "+" } else { "−" },
format_euros(entry.amount_micros.abs())
);
table.push(vec![
Cell::styled(
time::relative_time(&entry.created_at).unwrap_or_else(|| entry.created_at.clone()),
theme::muted(),
),
Cell::plain(entry.detail.clone()),
Cell::styled(amount, theme::strong()),
]);
}
table.print();
Ok(())
}
pub async fn require_second_factor(
client: &ApiClient,
reference: Option<&str>,
required: bool,
format: Format,
) -> Result<()> {
let workspace = client.find_workspace(reference).await?;
let answer = client
.require_second_factor(&workspace.id, required)
.await?;
if format.answered_in_json(&answer.raw) {
return Ok(());
}
let name = answer
.body
.workspace
.name
.style(theme::strong())
.to_string();
if answer.body.workspace.require_second_factor {
println!(
" {} {name} exige doble factor: quien no tenga passkey ni código de verificación no entra en sus proyectos.",
theme::CHECK.style(theme::success())
);
} else {
println!(
" {} {name} ya no exige doble factor.",
theme::CHECK.style(theme::success())
);
}
Ok(())
}
pub async fn members(client: &ApiClient, reference: Option<&str>, format: Format) -> Result<()> {
let workspace = client.find_workspace(reference).await?;
let (members, invitations) = tokio::join!(
client.members(&workspace.id),
client.invitations(&workspace.id)
);
if format.is_json() {
output::emit(&serde_json::json!({
"workspace": workspace,
"members": members?,
"invitations": invitations?,
}));
return Ok(());
}
println!();
println!(" {}", workspace.name.style(theme::strong()));
println!();
let mut table = Table::new(&["usuario", "github", "correo", "papel", "2fa", "visto"]);
for member in &members? {
table.push(vec![
Cell::styled(
member
.display_name
.clone()
.unwrap_or_else(|| member.user_id.clone()),
theme::strong(),
),
Cell::plain(
member
.github_login
.as_ref()
.map(|login| format!("@{login}"))
.unwrap_or_else(|| "—".to_string()),
),
Cell::plain(member.email.clone().unwrap_or_else(|| "—".to_string())),
Cell::plain(role_label(&member.role).to_string()),
match member.second_factor {
Some(true) => Cell::styled("sí", theme::success()),
Some(false) => Cell::styled(
"no",
if workspace.require_second_factor {
theme::danger()
} else {
theme::muted()
},
),
None => Cell::styled("—", theme::muted()),
},
Cell::styled(
member
.last_seen_at
.as_deref()
.and_then(time::relative_time)
.unwrap_or_else(|| "—".to_string()),
theme::muted(),
),
]);
}
table.print();
let pending: Vec<_> = invitations?
.into_iter()
.filter(|invitation| invitation.status == "pending")
.collect();
if pending.is_empty() {
return Ok(());
}
println!();
println!(" {}", "Invitaciones pendientes".style(theme::muted()));
println!();
let mut invitations = Table::new(&["id", "correo", "papel", "estado"]);
for invitation in &pending {
invitations.push(vec![
Cell::styled(invitation.id.clone(), theme::muted()),
Cell::styled(invitation.email.clone(), theme::strong()),
Cell::plain(role_label(&invitation.role).to_string()),
Cell::plain(status_label(&invitation.status).to_string()),
]);
}
invitations.print();
Ok(())
}
pub async fn invite(
client: &ApiClient,
reference: Option<&str>,
email: &str,
admin: bool,
) -> Result<()> {
let workspace = client.find_workspace(reference).await?;
let role = if admin { "admin" } else { "member" };
let sent = client.invite(&workspace.id, email, role).await?;
println!();
println!(
" {} Invitación enviada a {} como {}",
theme::CHECK.style(theme::success()),
email.style(theme::strong()),
role_label(role),
);
println!(" {}", sent.accept_url.style(theme::muted()));
println!();
Ok(())
}
pub async fn revoke(
client: &ApiClient,
reference: Option<&str>,
invitation_id: &str,
) -> Result<()> {
let workspace = client.find_workspace(reference).await?;
client
.revoke_invitation(&workspace.id, invitation_id)
.await?;
println!(
" {} Invitación retirada",
theme::CHECK.style(theme::success())
);
Ok(())
}
pub async fn remove(client: &ApiClient, reference: Option<&str>, user_id: &str) -> Result<()> {
let workspace = client.find_workspace(reference).await?;
client.remove_member(&workspace.id, user_id).await?;
println!(
" {} {user_id} ya no está en {}",
theme::CHECK.style(theme::success()),
workspace.name.style(theme::strong()),
);
Ok(())
}