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 {
6666 /// The user to verify.
6767 name: String,
6868 },
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+ },
6977 /// Delete a user. Refuses if the user still owns repositories unless
7078 /// `--purge` is given.
7179 Del {
@@ -106,6 +114,7 @@ pub(crate) fn run(
106114 UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)),
107115 UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)),
108116 UserCommand::Verify { name } => block_on(verify(config_path, name)),
117+ UserCommand::Admin { name, revoke } => block_on(set_admin(config_path, name, !*revoke)),
109118 UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)),
110119 }
111120 }
@@ -304,6 +313,31 @@ async fn verify(config_path: Option<&Path>, name: &str) -> anyhow::Result<ExitCo
304313 Ok(ExitCode::SUCCESS)
305314 }
306315
316+/// Grant or revoke a user's site-administrator flag.
317+async 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+
307341 async fn del(
308342 config_path: Option<&Path>,
309343 name: &str,
@@ -520,6 +554,32 @@ mod tests {
520554 }
521555
522556 #[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]
523583 fn del_refuses_repo_owner_without_purge_then_purges() {
524584 let env = env();
525585 block_on(add(