OXYGENHUB

Admins disable, enable and delete accounts 6a1780558353

Disabled accounts are refused wherever banned ones are (one blocked()
check), with their own message at sign-in and over the protocol. Delete
is confirmed by typing the username, refused while the account owns
repositories or is an organisation's only owner, keeps the last admin,
and retires the name. Migration 0031: accounts.disabled, the retired
names, and proposal reviews kept (author_id set null) when their author
is deleted. Test; the migration was tried on a copy of Earth's data.

closes #81
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 16:08 UTC
Signature
✔ signed by Andy McCall
Hash
6a17805583532833680cc1bf30cc7c63cd4c6778aa3532b30dd8528f54b481fa
Depends on
19bb31ff1f5c 2291f8cc9c96 34beb1a7e376 749d9a5fcd9f 8fb35df7d66e b5668108487f b64e2537d702 d23464c0ac60 de505df5cf8d
Depended on by
737de0fac722 7c06f5b28ade 559c39204234 c8684d44d549 d1bf2d698777 5d90e0e045e2 0024ccb1b050 5dead1f4f26d cb71e18eb6f9
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
36 atoms, 1 tree operation, 15 files

Browse the tree at this change

added crates/hub/migrations/0031_account_disable_delete.sql +27 −0
1 +
-- Disabled and deleted accounts (oxygenhub#81). A disabled account is
2 +
-- refused like a banned one, with its own message, and can be enabled
3 +
-- again. A deleted account's row goes (its tokens, keys and memberships
4 +
-- with it); its username is retired so nobody can take it over.
5 +
ALTER TABLE accounts ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0;
6 +
7 +
CREATE TABLE retired_usernames (
8 +
    username   TEXT PRIMARY KEY COLLATE NOCASE,
9 +
    retired_ms INTEGER NOT NULL
10 +
);
11 +
12 +
-- A review keeps its author's name when the account is deleted, as issues
13 +
-- and comments do, rather than going with it.
14 +
CREATE TABLE proposal_reviews_kept (
15 +
    id          INTEGER PRIMARY KEY,
16 +
    proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
17 +
    author_id   INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
18 +
    author      TEXT NOT NULL DEFAULT '',
19 +
    verdict     TEXT NOT NULL CHECK (verdict IN ('comment', 'changes', 'approve')),
20 +
    body        TEXT NOT NULL DEFAULT '',
21 +
    created_ms  INTEGER NOT NULL
22 +
);
23 +
INSERT INTO proposal_reviews_kept (id, proposal_id, author_id, author, verdict, body, created_ms)
24 +
    SELECT id, proposal_id, author_id, author, verdict, body, created_ms FROM proposal_reviews;
25 +
DROP TABLE proposal_reviews;
26 +
ALTER TABLE proposal_reviews_kept RENAME TO proposal_reviews;
27 +
CREATE INDEX proposal_reviews_by_proposal ON proposal_reviews (proposal_id, created_ms);
modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Disable and delete accounts (#81).** Administration offers Disable and Enable beside Ban: a disabled account is refused like a banned one (sign-in, sessions, tokens, pushes), and signing in says "Your account has been disabled. Contact the hub's administrator." Delete removes an account for good after its username is typed again: its tokens, keys and memberships go, what it wrote stays under its name, and the name stays retired so nobody can take it over. It is refused, naming them, while the account owns repositories or is the only owner of an organisation; nobody disables or deletes themselves or the last admin who can sign in. A deleted account's proposal reviews now stay, as its comments do.
8 +
7 9
- **Labels from `oh` (#80).** `oh proposal label N --add NAME --remove NAME` and `oh issue label N …` put labels on or take them off (triage), and `oh label list|create|delete` manage the repository's labels. Through the API, `PATCH /proposals/{n}` takes `labels` (the whole set, by name, every name checked before anything changes), and a proposal's JSON lists its labels.
8 10
9 11
- **A clearer release trust page (#79).** A row of badges at the top says whether the tag is signed and by whom, how many changes are signed or sealed, and how many assets an attested OxygenFlow build produced. The graph is drawn at its own size (it filled the screen), drops the builds column when nothing was built, and marks every box: a lock where the claim is signed and checked, `!` where it is not (unsigned, uploaded by hand), `✘` where it fails, with the reason underneath and the details on hover. Each asset's heading carries its badge too. The GitHub look no longer strokes the graph's text.
…
33 unchanged lines
modified crates/hub/src/api/mod.rs +1 −1
…
295 unchanged lines
296 296
    let token = db::accounts::token_by_hash(&app.db, &auth::token_hash(t)).await?;
297 297
    match token {
298 298
        Some(tok) if !tok.expired(db::now_ms()) => {
299 -
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.banned);
299 +
            let account = db::accounts::account_by_id(&app.db, tok.account_id).await?.filter(|a| !a.blocked());
300 300
            db::accounts::touch_token(&app.db, tok.id).await.ok();
301 301
            Ok(Caller {
302 302
                account,
…
1703 unchanged lines
modified crates/hub/src/auth/roles.rs +2 −2
…
66 unchanged lines
67 67
    let Some(account) = account else {
68 68
        return Ok(if visibility == Visibility::Public { Some(Role::Read) } else { None });
69 69
    };
70 -
    if account.banned {
70 +
    if account.blocked() {
71 71
        return Ok(None);
72 72
    }
73 73
    if account.is_admin || account.username.eq_ignore_ascii_case(&repo.owner) {
…
23 unchanged lines
97 97
98 98
/// Whether an account may create repositories under `owner`.
99 99
pub async fn may_create_under(pool: &Pool, account: &Account, owner: &str) -> sqlx::Result<bool> {
100 -
    if account.banned {
100 +
    if account.blocked() {
101 101
        return Ok(false);
102 102
    }
103 103
    if account.username.eq_ignore_ascii_case(owner) || account.is_admin {
…
15 unchanged lines
modified crates/hub/src/auth/viewer.rs +1 −1
…
57 unchanged lines
58 58
pub async fn resolve(app: &Shared, value: &str) -> Option<ViewerAccount> {
59 59
    let (username, mac) = value.rsplit_once('.')?;
60 60
    let account = db::accounts::account_by_name(&app.db, username).await.ok()??;
61 -
    if account.banned {
61 +
    if account.blocked() {
62 62
        return None;
63 63
    }
64 64
    let expected = session_value(&app.cookie_key, &account.username, &account.session_salt);
…
25 unchanged lines
modified crates/hub/src/db/accounts.rs +47 −0
…
16 unchanged lines
17 17
    pub recovery_codes: String,
18 18
    pub is_admin: bool,
19 19
    pub banned: bool,
20 +
    /// Disabled by an admin: refused like a ban, and enabled again (0031).
21 +
    #[sqlx(default)]
22 +
    pub disabled: bool,
20 23
    pub created_ms: i64,
21 24
    pub last_seen_ms: i64,
22 25
    pub email_notifications: bool,
…
10 unchanged lines
33 36
}
34 37
35 38
impl Account {
39 +
    /// Whether the account may not sign in or act: banned or disabled.
40 +
    pub fn blocked(&self) -> bool {
41 +
        self.banned || self.disabled
42 +
    }
43 +
36 44
    pub fn name(&self) -> &str {
37 45
        if self.display_name.is_empty() {
38 46
            &self.username
…
178 unchanged lines
217 225
218 226
pub async fn set_banned(pool: &Pool, id: i64, banned: bool) -> sqlx::Result<()> {
219 227
    sqlx::query("UPDATE accounts SET banned = ? WHERE id = ?").bind(banned).bind(id).execute(pool).await?;
228 +
    Ok(())
229 +
}
230 +
231 +
pub async fn set_disabled(pool: &Pool, id: i64, disabled: bool) -> sqlx::Result<()> {
232 +
    sqlx::query("UPDATE accounts SET disabled = ? WHERE id = ?").bind(disabled).bind(id).execute(pool).await?;
220 233
    Ok(())
234 +
}
235 +
236 +
/// Delete an account (oxygenhub#81): its row goes, and with it (by the
237 +
/// schema's cascades) its tokens, keys, sessions' salt, memberships, stars
238 +
/// and notifications; what it wrote keeps its author's name. The username
239 +
/// is retired so nobody can register it and pass for the account.
240 +
pub async fn delete_account(pool: &Pool, id: i64, username: &str) -> sqlx::Result<()> {
241 +
    let mut tx = super::begin_write(pool).await?;
242 +
    sqlx::query("INSERT OR IGNORE INTO retired_usernames (username, retired_ms) VALUES (?, ?)")
243 +
        .bind(username)
244 +
        .bind(now_ms())
245 +
        .execute(&mut *tx)
246 +
        .await?;
247 +
    sqlx::query("DELETE FROM accounts WHERE id = ?").bind(id).execute(&mut *tx).await?;
248 +
    tx.commit().await
249 +
}
250 +
251 +
/// The organisations whose only owner is this account: deleting it would
252 +
/// leave them with nobody to manage them.
253 +
pub async fn orgs_owned_only_by(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<String>> {
254 +
    sqlx::query_scalar(
255 +
        "SELECT o.name FROM organisations o JOIN org_members m ON m.org_id = o.id \
256 +
         WHERE m.account_id = ? AND m.role = 'owner' \
257 +
           AND (SELECT COUNT(*) FROM org_members x WHERE x.org_id = o.id AND x.role = 'owner') = 1 \
258 +
         ORDER BY o.name",
259 +
    )
260 +
    .bind(account_id)
261 +
    .fetch_all(pool)
262 +
    .await
263 +
}
264 +
265 +
/// Whether `name` belonged to a deleted account.
266 +
pub async fn username_retired(pool: &Pool, name: &str) -> sqlx::Result<bool> {
267 +
    Ok(sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM retired_usernames WHERE username = ?").bind(name).fetch_one(pool).await? > 0)
221 268
}
222 269
223 270
pub async fn touch_seen(pool: &Pool, id: i64) -> sqlx::Result<()> {
…
611 unchanged lines
modified crates/hub/src/db/proposals.rs +2 −1
…
67 unchanged lines
68 68
pub struct ReviewRow {
69 69
    pub id: i64,
70 70
    pub proposal_id: i64,
71 -
    pub author_id: i64,
71 +
    /// `None` once the author's account was deleted (its name stays).
72 +
    pub author_id: Option<i64>,
72 73
    pub author: String,
73 74
    pub verdict: String,
74 75
    pub body: String,
…
282 unchanged lines
modified crates/hub/src/main.rs +3 −2
…
238 unchanged lines
239 239
        UserCmd::List => {
240 240
            for a in accounts::accounts(&app.db).await? {
241 241
                println!(
242 -
                    "{:<24} {:<32} {}{}",
242 +
                    "{:<24} {:<32} {}{}{}",
243 243
                    a.username,
244 244
                    a.email,
245 245
                    if a.is_admin { "admin " } else { "" },
246 -
                    if a.banned { "banned" } else { "" }
246 +
                    if a.banned { "banned " } else { "" },
247 +
                    if a.disabled { "disabled" } else { "" }
247 248
                );
248 249
            }
249 250
        }
…
33 unchanged lines
modified crates/hub/src/protocol.rs +3 −0
…
84 unchanged lines
85 85
        if account.banned {
86 86
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is banned".into()));
87 87
        }
88 +
        if account.disabled {
89 +
            return Err(Failure(StatusCode::UNAUTHORIZED, "this account is disabled; contact the hub's administrator".into()));
90 +
        }
88 91
        let _ = self.block(db::accounts::touch_token(&pool, token.id));
89 92
        Ok(Caller::Token(Box::new(account), Box::new(token)))
90 93
    }
…
187 unchanged lines
modified crates/hub/src/web/admin.rs +36 −3
…
81 unchanged lines
82 82
    csrf_token: String,
83 83
    username: String,
84 84
    action: String,
85 +
    /// For delete: the username typed again.
86 +
    #[serde(default)]
87 +
    confirm: String,
85 88
}
86 89
87 90
pub async fn account(State(app): State<Shared>, viewer: Viewer, Form(f): Form<AccountForm>) -> Result<Page<AdminPage>, Error> {
…
3 unchanged lines
91 94
    let Some(target) = accounts::account_by_name(&app.db, f.username.trim()).await.map_err(|e| Error::from_db(shell.clone(), e))? else {
92 95
        return admin_page(&app, shell, Some("No such account.".into())).await;
93 96
    };
94 -
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote") {
97 +
    if target.id == me.id && matches!(f.action.as_str(), "ban" | "demote" | "disable" | "delete") {
95 98
        return admin_page(&app, shell, Some("Not on yourself.".into())).await;
96 99
    }
97 100
    let e = |e| Error::from_db(shell.clone(), e);
101 +
    // The hub must keep an admin who can sign in (oxygenhub#81).
102 +
    if target.is_admin && matches!(f.action.as_str(), "disable" | "delete") {
103 +
        let others =
104 +
            accounts::accounts(&app.db).await.map_err(e)?.into_iter().filter(|a| a.is_admin && !a.blocked() && a.id != target.id).count();
105 +
        if others == 0 {
106 +
            return admin_page(&app, shell, Some(format!("{} is the last admin who can sign in.", target.username))).await;
107 +
        }
108 +
    }
109 +
    if f.action == "delete" {
110 +
        if f.confirm.trim() != target.username {
111 +
            return admin_page(&app, shell, Some(format!("Not deleted: type {} to confirm.", target.username))).await;
112 +
        }
113 +
        let repos = crate::db::repos::list_by_owner(&app.db, &target.username).await.map_err(e)?;
114 +
        let orgs = accounts::orgs_owned_only_by(&app.db, target.id).await.map_err(e)?;
115 +
        if !repos.is_empty() || !orgs.is_empty() {
116 +
            let mut held: Vec<String> = repos.iter().map(|r| r.slug()).collect();
117 +
            held.extend(orgs.iter().map(|o| format!("organisation {o} (sole owner)")));
118 +
            return admin_page(
119 +
                &app,
120 +
                shell,
121 +
                Some(format!("Not deleted: {} still owns {}. Transfer or delete them first.", target.username, held.join(", "))),
122 +
            )
123 +
            .await;
124 +
        }
125 +
        accounts::delete_account(&app.db, target.id, &target.username).await.map_err(e)?;
126 +
        accounts::audit(&app.db, Some(&me), "admin.account.delete", &target.username, "").await.ok();
127 +
        return admin_page(&app, shell, Some(format!("{} deleted; the name stays retired.", target.username))).await;
128 +
    }
98 129
    if f.action == "reset_link" {
99 -
        if target.banned {
100 -
            return admin_page(&app, shell, Some("A banned account cannot be reset.".into())).await;
130 +
        if target.blocked() {
131 +
            return admin_page(&app, shell, Some("A banned or disabled account cannot be reset.".into())).await;
101 132
        }
102 133
        let url = super::recovery::admin_reset_link(&app, &me, &target).await.map_err(e)?;
103 134
        return admin_page(
…
6 unchanged lines
110 141
    match f.action.as_str() {
111 142
        "ban" => accounts::set_banned(&app.db, target.id, true).await.map_err(e)?,
112 143
        "unban" => accounts::set_banned(&app.db, target.id, false).await.map_err(e)?,
144 +
        "disable" => accounts::set_disabled(&app.db, target.id, true).await.map_err(e)?,
145 +
        "enable" => accounts::set_disabled(&app.db, target.id, false).await.map_err(e)?,
113 146
        "promote" => accounts::set_admin(&app.db, target.id, true).await.map_err(e)?,
114 147
        "demote" => accounts::set_admin(&app.db, target.id, false).await.map_err(e)?,
115 148
        _ => return Err(Error::bad(shell, "unknown action")),
…
123 unchanged lines
modified crates/hub/src/web/orgs.rs +2 −1
…
43 unchanged lines
44 44
        .into_response());
45 45
    }
46 46
    let taken = accounts::account_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
47 -
        || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some();
47 +
        || accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
48 +
        || accounts::username_retired(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?;
48 49
    if taken {
49 50
        return Ok(Page(OrgNewPage { shell, error: Some("That name is taken.".into()) }).into_response());
50 51
    }
…
310 unchanged lines
modified crates/hub/src/web/recovery.rs +2 −2
…
108 unchanged lines
109 109
    } else {
110 110
        accounts::account_by_name(&app.db, who).await.map_err(e)?
111 111
    };
112 -
    if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.banned) {
112 +
    if let Some(a) = account.filter(|a| a.email_verified && !a.email.is_empty() && !a.blocked()) {
113 113
        let recent = accounts::last_link_ms(&app.db, a.id, "reset", None).await.map_err(e)?;
114 114
        if !recent.is_some_and(|t| db::now_ms() - t < MAIL_EVERY_MS) {
115 115
            let secret = accounts::create_link(&app.db, a.id, "reset", "", None, RESET_TTL_MS).await.map_err(e)?;
…
18 unchanged lines
134 134
async fn reset_target(app: &Shared, secret: &str) -> sqlx::Result<Option<(accounts::AccountLink, accounts::Account)>> {
135 135
    let Some(link) = accounts::live_link(&app.db, "reset", secret).await? else { return Ok(None) };
136 136
    let account = accounts::account_by_id(&app.db, link.account_id).await?;
137 -
    Ok(account.filter(|a| !a.banned).map(|a| (link, a)))
137 +
    Ok(account.filter(|a| !a.blocked()).map(|a| (link, a)))
138 138
}
139 139
140 140
/// `GET /login/reset/{secret}`.
…
61 unchanged lines
modified crates/hub/src/web/session.rs +5 −0
…
68 unchanged lines
69 69
    if account.banned {
70 70
        return Ok(fail("This account is banned.", None));
71 71
    }
72 +
    if account.disabled {
73 +
        accounts::audit(&app.db, None, "login.disabled", &username, "").await.ok();
74 +
        return Ok(fail("Your account has been disabled. Contact the hub's administrator.", None));
75 +
    }
72 76
    if let Some(secret) = &account.totp_secret {
73 77
        if f.code.trim().is_empty() {
74 78
            return Ok(fail("Enter the code from your authenticator app.", Some(username)));
…
109 unchanged lines
184 188
    }
185 189
    if accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
186 190
        || accounts::org_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some()
191 +
        || accounts::username_retired(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?
187 192
    {
188 193
        return Ok(fail("That name is taken."));
189 194
    }
…
13 unchanged lines
modified crates/hub/tests/accounts.rs +63 −0
…
477 unchanged lines
478 478
    let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
479 479
    rd.flatten().map(|e| if e.path().is_dir() { walk_files(&e.path()) } else { 1 }).sum()
480 480
}
481 +
482 +
/// oxygenhub#81: an admin disables an account (sign-in says so, its session
483 +
/// and pushes stop) and enables it again; deletes one, refused while it
484 +
/// owns a repository, after which its name stays retired.
485 +
#[tokio::test(flavor = "multi_thread")]
486 +
async fn admins_disable_enable_and_delete_accounts() {
487 +
    let h = hub().await;
488 +
    let app = &h.app;
489 +
    let andy = register(app, "andy", "").await;
490 +
    let invite = |app: Shared, andy: String| async move {
491 +
        let csrf = csrf_of(&get(&app, "/admin", Some(&andy)).await.body);
492 +
        let r = post(&app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await;
493 +
        r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string()
494 +
    };
495 +
    let bob = register(app, "bob", &invite(app.clone(), andy.clone()).await).await;
496 +
    let act = |who: &'static str, action: &'static str, confirm: &'static str| {
497 +
        let (app, andy) = (app.clone(), andy.clone());
498 +
        async move {
499 +
            let csrf = csrf_of(&get(&app, "/admin", Some(&andy)).await.body);
500 +
            post(&app, "/admin/account", Some(&andy), &format!("_csrf={csrf}&username={who}&action={action}&confirm={confirm}")).await.body
501 +
        }
502 +
    };
503 +
    let login = |user: &'static str| {
504 +
        let app = app.clone();
505 +
        async move { post(&app, "/login", None, &format!("username={user}&password=correct-horse-battery")).await }
506 +
    };
507 +
508 +
    // Disabled: sign-in says so, the old session no longer counts.
509 +
    act("bob", "disable", "").await;
510 +
    assert!(db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().disabled);
511 +
    let r = login("bob").await;
512 +
    assert!(r.body.contains("Your account has been disabled"), "{}", r.body);
513 +
    assert!(r.cookie.is_none());
514 +
    assert_eq!(get(app, "/settings", Some(&bob)).await.status, StatusCode::SEE_OTHER, "the session is refused");
515 +
    assert!(act("andy", "disable", "").await.contains("Not on yourself"));
516 +
    // Enabled again: sign-in works.
517 +
    act("bob", "enable", "").await;
518 +
    assert_eq!(login("bob").await.status, StatusCode::SEE_OTHER);
519 +
520 +
    // Delete: confirmed by name, refused while Bob owns a repository.
521 +
    let bob = login("bob").await.cookie.unwrap();
522 +
    let csrf = csrf_of(&get(app, "/new", Some(&bob)).await.body);
523 +
    assert_eq!(
524 +
        post(app, "/new", Some(&bob), &format!("_csrf={csrf}&owner=bob&name=keep&visibility=public")).await.status,
525 +
        StatusCode::SEE_OTHER
526 +
    );
527 +
    assert!(act("bob", "delete", "bo").await.contains("type bob to confirm"));
528 +
    let page = act("bob", "delete", "bob").await;
529 +
    assert!(page.contains("still owns bob/keep"), "{page}");
530 +
    assert!(db::accounts::account_by_name(&app.db, "bob").await.unwrap().is_some());
531 +
    let row = db::repos::get(&app.db, "bob", "keep").await.unwrap().unwrap();
532 +
    db::repos::delete(&app.db, row.id).await.unwrap();
533 +
    let page = act("bob", "delete", "bob").await;
534 +
    assert!(page.contains("bob deleted"), "{page}");
535 +
    assert!(db::accounts::account_by_name(&app.db, "bob").await.unwrap().is_none());
536 +
    assert_eq!(get(app, "/settings", Some(&bob)).await.status, StatusCode::SEE_OTHER);
537 +
    // Its name stays retired.
538 +
    let code = invite(app.clone(), andy.clone()).await;
539 +
    let r = post(app, "/register", None, &format!("username=bob&email=b%40example.com&password=correct-horse-battery&invite={code}")).await;
540 +
    assert!(r.body.contains("That name is taken"), "{}", r.body);
541 +
    // The last admin can be neither disabled nor deleted.
542 +
    assert!(act("andy", "delete", "andy").await.contains("Not on yourself"));
543 +
}
modified templates/admin.html +6 −2
…
9 unchanged lines
10 10
  <h2>Accounts</h2>
11 11
  <table class="small"><tr><th>Username</th><th>Email</th><th>Flags</th><th>Last seen</th><th></th></tr>
12 12
  {% for a in accounts %}
13 -
  <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}{% if !a.email.is_empty() %} <span class="small mute" title="{% if a.email_verified %}verified{% else %}not verified{% endif %}">{% if a.email_verified %}✔{% else %}○{% endif %}</span>{% endif %}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td>
13 +
  <tr><td><a href="/{{ a.username }}">{{ a.username }}</a></td><td>{{ a.email }}{% if !a.email.is_empty() %} <span class="small mute" title="{% if a.email_verified %}verified{% else %}not verified{% endif %}">{% if a.email_verified %}✔{% else %}○{% endif %}</span>{% endif %}</td><td>{% if a.is_admin %}<span class="tag">admin</span>{% endif %}{% if a.banned %} <span class="tag bad">banned</span>{% endif %}{% if a.disabled %} <span class="tag warn">disabled</span>{% endif %}{% if a.totp_enabled() %} <span class="tag">2fa</span>{% endif %}</td>
14 14
  <td>{% if a.last_seen_ms > 0 %}<time title="{{ a.last_seen_ms|when_i }}">{{ a.last_seen_ms|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
15 15
  <td><form class="tiny" method="post" action="/admin/account"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}">
16 16
    {% if a.banned %}<button name="action" value="unban">unban</button>{% else %}<button name="action" value="ban">ban</button>{% endif %}
17 17
    {% if a.is_admin %}<button name="action" value="demote">demote</button>{% else %}<button name="action" value="promote">make admin</button>{% endif %}
18 -
    {% if !a.banned %}<button name="action" value="reset_link" title="A one-time password reset link to hand over">reset link</button>{% endif %}</form></td></tr>
18 +
    {% if a.disabled %}<button name="action" value="enable">enable</button>{% else %}<button name="action" value="disable" title="Refuse sign-in, sessions, tokens and pushes until enabled again">disable</button>{% endif %}
19 +
    {% if !a.blocked() %}<button name="action" value="reset_link" title="A one-time password reset link to hand over">reset link</button>{% endif %}</form>
20 +
    <details class="tiny delete-account"><summary>delete</summary><form class="tiny" method="post" action="/admin/account"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="username" value="{{ a.username }}">
21 +
    <label class="small">Deleting removes the account, its tokens, keys and memberships for good; what it wrote stays under its name, which stays retired. Type <strong>{{ a.username }}</strong> to confirm:
22 +
    <input name="confirm" autocomplete="off" required></label> <button class="danger" name="action" value="delete">delete {{ a.username }}</button></form></details></td></tr>
19 23
  {% endfor %}
20 24
  </table>
21 25
</section>
…
22 unchanged lines