fabrica

hanna/fabrica

feat(cli): user admin subcommand to grant/revoke admin

4bbd7b9 · hanna committed on 2026-07-26

`fabrica user admin <name>` grants the site-administrator flag on an
existing account; `--revoke` removes it. Previously admin could only be
set at creation with `user add --admin`. Idempotent: a no-op is
reported rather than re-written.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
1 files changed · +60 −0UnifiedSplit
crates/cli/src/user_cmd.rs +60 −0
@@ -66,6 +66,14 @@ pub(crate) enum UserCommand {
66 /// The user to verify.66 /// The user to verify.
67 name: String,67 name: String,
68 },68 },
69 /// Grant (or, with `--revoke`, remove) a user's site-administrator flag.
70 Admin {
71 /// The user to promote or demote.
72 name: String,
73 /// Revoke admin instead of granting it.
74 #[arg(long)]
75 revoke: bool,
76 },
69 /// Delete a user. Refuses if the user still owns repositories unless77 /// Delete a user. Refuses if the user still owns repositories unless
70 /// `--purge` is given.78 /// `--purge` is given.
71 Del {79 Del {
@@ -106,6 +114,7 @@ pub(crate) fn run(
106 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),114 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),
107 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),115 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),
108 UserCommand::Verify { name } => block_on(verify(config_path, name)),116 UserCommand::Verify { name } => block_on(verify(config_path, name)),
117 UserCommand::Admin { name, revoke } => block_on(set_admin(config_path, name, !*revoke)),
109 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),118 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),
110 }119 }
111}120}
@@ -304,6 +313,31 @@ async fn verify(config_path: Option<&Path>, name: &str) -> anyhow::Result<ExitCo
304 Ok(ExitCode::SUCCESS)313 Ok(ExitCode::SUCCESS)
305}314}
306315
316/// Grant or revoke a user's site-administrator flag.
317async fn set_admin(
318 config_path: Option<&Path>,
319 name: &str,
320 admin: bool,
321) -> anyhow::Result<ExitCode> {
322 let (_config, store) = open_store(config_path).await?;
323 let user = require_user(&store, name).await?;
324 if user.is_admin == admin {
325 println!(
326 "{} is already {}an administrator",
327 user.username,
328 if admin { "" } else { "not " }
329 );
330 return Ok(ExitCode::SUCCESS);
331 }
332 store.set_admin(&user.id, admin).await?;
333 println!(
334 "{} admin for {}",
335 if admin { "granted" } else { "revoked" },
336 user.username
337 );
338 Ok(ExitCode::SUCCESS)
339}
340
307async fn del(341async fn del(
308 config_path: Option<&Path>,342 config_path: Option<&Path>,
309 name: &str,343 name: &str,
@@ -520,6 +554,32 @@ mod tests {
520 }554 }
521555
522 #[test]556 #[test]
557 fn admin_grants_then_revokes_the_flag() {
558 let env = env();
559 block_on(add(
560 env.path(),
561 "erin",
562 "erin@example.com",
563 Some("pw".to_string()),
564 false,
565 None,
566 ))
567 .unwrap();
568
569 block_on(set_admin(env.path(), "erin", true)).unwrap();
570 let promoted =
571 block_on(async { anyhow::Ok(require_user(&env.store().await, "erin").await?) })
572 .unwrap();
573 assert!(promoted.is_admin);
574
575 block_on(set_admin(env.path(), "erin", false)).unwrap();
576 let demoted =
577 block_on(async { anyhow::Ok(require_user(&env.store().await, "erin").await?) })
578 .unwrap();
579 assert!(!demoted.is_admin);
580 }
581
582 #[test]
523 fn del_refuses_repo_owner_without_purge_then_purges() {583 fn del_refuses_repo_owner_without_purge_then_purges() {
524 let env = env();584 let env = env();
525 block_on(add(585 block_on(add(