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 |