Advanced settings: transfer, archive, delete, wiki placeholder d0fbea2eb46e
The Delete section becomes Advanced. Transfer moves a repository to a user or organisation the admin may create under, keeping its name: the directory and mirror move under the write lock, and a redirect row keeps the old address working on the web, for oxy remotes and the API. Archive makes it read-only with a reason and a badge: the wire protocol refuses pushes and the web refuses edits, issues, proposals, comments and reviews until an admin brings it back. Delete wiki data waits for wikis. closes #108
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 16:58 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
d0fbea2eb46e795e5c9f4061a786a32faa1667c4ff853dccf3ce1fe472d5e114- Depends on
- 07b1a5bbf481 145306044896 18e381ea88c2 19bb31ff1f5c 1ac84c62ba53 64caed18236a 749d9a5fcd9f 7d2eb590051f 83d5052bf76c 8933faff2043 9008bf6c2b8c 93be9ce4653e b5668108487f b64e2537d702 d1727c573f30 d23464c0ac60 de505df5cf8d
- Depended on by
- 166f6e9bf4f1 c7067c7bda56 0ee5c3fb32eb 4a68f1064b08 7c06f5b28ade c8684d44d549 5d90e0e045e2 2a0c09c09716 d3b048b27277 f4d6ef418a96 e71583501b1d 1f78655a0466 66fd9d6b1452
- 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.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, 14 files
Browse the tree at this change
added crates/hub/migrations/0040_advanced.sql +12 −0
| 1 | + | -- Advanced settings (hub spec §6, #108): a repository can be archived |
|
| 2 | + | -- (read-only, with a reason) and transferred to another owner; the old |
|
| 3 | + | -- name redirects on the web and still resolves for oxy remotes. |
|
| 4 | + | ALTER TABLE repositories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0; |
|
| 5 | + | ALTER TABLE repositories ADD COLUMN archived_reason TEXT NOT NULL DEFAULT ''; |
|
| 6 | + | CREATE TABLE repo_redirects ( |
|
| 7 | + | old_owner TEXT NOT NULL, |
|
| 8 | + | old_name TEXT NOT NULL, |
|
| 9 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 10 | + | moved_ms INTEGER NOT NULL, |
|
| 11 | + | PRIMARY KEY (old_owner, old_name) |
|
| 12 | + | ); |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Advanced settings: transfer, archive, delete (#108).** A repository's Delete section becomes Advanced: transfer the repository to a user or organisation (the old address redirects and `oxy` remotes keep working), archive it read-only with a reason and bring it back, delete it, and a placeholder for deleting wiki data ahead of wikis. |
|
| 8 | + | ||
| 7 | 9 | - **Accounts elsewhere on a profile (#105).** Bluesky, Mastodon and GitHub under Settings › Profile, checked and normalised (a handle, `@user@instance` or a profile URL all work), shown on the profile with the service's icon as `rel="me"` links. |
|
| 8 | 10 | ||
| 9 | 11 | - **A job's identity: the hub is an OIDC issuer (#103).** A job that declares `identity "AUDIENCE" { mount-path … }` gets a signed token naming the repository, bookmark, run and job, delivered as a file like a secret; `/.well-known/openid-configuration` and `/.well-known/jwks.json` publish what AWS, Google Cloud or Vault need to trust it. Tokens are masked in logs. |
|
| … | 103 unchanged lines |
modified crates/hub/src/db/repos.rs +51 −0
| … | 26 unchanged lines |
||
| 27 | 27 | /// OxygenFlow: `on`, `off`, or empty to follow hub.toml (migration 0018). |
|
| 28 | 28 | #[sqlx(default)] |
|
| 29 | 29 | pub flow: String, |
|
| 30 | + | /// Read-only, with why (migration 0040, #108). |
|
| 31 | + | #[sqlx(default)] |
|
| 32 | + | pub archived: bool, |
|
| 33 | + | #[sqlx(default)] |
|
| 34 | + | pub archived_reason: String, |
|
| 30 | 35 | /// Counts for the tabs; filled by `get` only. |
|
| 31 | 36 | #[sqlx(default)] |
|
| 32 | 37 | pub open_issues: i64, |
|
| … | 80 unchanged lines |
||
| 113 | 118 | .bind(now) |
|
| 114 | 119 | .fetch_one(pool) |
|
| 115 | 120 | .await |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | /// The repository an old name now points at, after a transfer. |
|
| 124 | + | pub async fn moved_to(pool: &Pool, owner: &str, name: &str) -> sqlx::Result<Option<RepoRow>> { |
|
| 125 | + | let id: Option<i64> = sqlx::query_scalar("SELECT repo_id FROM repo_redirects WHERE old_owner = ? AND old_name = ?") |
|
| 126 | + | .bind(owner) |
|
| 127 | + | .bind(name) |
|
| 128 | + | .fetch_optional(pool) |
|
| 129 | + | .await?; |
|
| 130 | + | match id { |
|
| 131 | + | Some(id) => get_by_id(pool, id).await, |
|
| 132 | + | None => Ok(None), |
|
| 133 | + | } |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | /// Move a repository to another owner, keeping its name: the row, and a |
|
| 137 | + | /// redirect from the old name. A redirect that pointed at the new name |
|
| 138 | + | /// goes, so names never loop. |
|
| 139 | + | pub async fn transfer(pool: &Pool, id: i64, old_owner: &str, name: &str, new_owner: &str) -> sqlx::Result<()> { |
|
| 140 | + | sqlx::query("UPDATE repositories SET owner = ?, updated_ms = ? WHERE id = ?") |
|
| 141 | + | .bind(new_owner) |
|
| 142 | + | .bind(now_ms()) |
|
| 143 | + | .bind(id) |
|
| 144 | + | .execute(pool) |
|
| 145 | + | .await?; |
|
| 146 | + | sqlx::query("DELETE FROM repo_redirects WHERE old_owner = ? AND old_name = ?").bind(new_owner).bind(name).execute(pool).await?; |
|
| 147 | + | sqlx::query("INSERT OR REPLACE INTO repo_redirects (old_owner, old_name, repo_id, moved_ms) VALUES (?, ?, ?, ?)") |
|
| 148 | + | .bind(old_owner) |
|
| 149 | + | .bind(name) |
|
| 150 | + | .bind(id) |
|
| 151 | + | .bind(now_ms()) |
|
| 152 | + | .execute(pool) |
|
| 153 | + | .await?; |
|
| 154 | + | Ok(()) |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | /// Archive (read-only) or bring back a repository. |
|
| 158 | + | pub async fn set_archived(pool: &Pool, id: i64, archived: bool, reason: &str) -> sqlx::Result<()> { |
|
| 159 | + | sqlx::query("UPDATE repositories SET archived = ?, archived_reason = ?, updated_ms = ? WHERE id = ?") |
|
| 160 | + | .bind(archived) |
|
| 161 | + | .bind(if archived { reason } else { "" }) |
|
| 162 | + | .bind(now_ms()) |
|
| 163 | + | .bind(id) |
|
| 164 | + | .execute(pool) |
|
| 165 | + | .await?; |
|
| 166 | + | Ok(()) |
|
| 116 | 167 | } |
|
| 117 | 168 | ||
| 118 | 169 | pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> { |
|
| … | 155 unchanged lines |
modified crates/hub/src/protocol.rs +17 −3
| … | 55 unchanged lines |
||
| 56 | 56 | let pool = self.pool.clone(); |
|
| 57 | 57 | match self.block(db::repos::get(&pool, &name.owner, &name.name)) { |
|
| 58 | 58 | Ok(Some(r)) => Ok(r), |
|
| 59 | - | Ok(None) => Err(not_found()), |
|
| 59 | + | // A transferred repository still answers to its old name (#108). |
|
| 60 | + | Ok(None) => match self.block(db::repos::moved_to(&pool, &name.owner, &name.name)) { |
|
| 61 | + | Ok(Some(r)) => Ok(r), |
|
| 62 | + | Ok(None) => Err(not_found()), |
|
| 63 | + | Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), |
|
| 64 | + | }, |
|
| 60 | 65 | Err(e) => Err(Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), |
|
| 61 | 66 | } |
|
| 62 | 67 | } |
|
| … | 40 unchanged lines |
||
| 103 | 108 | ||
| 104 | 109 | impl Backend for HubBackend { |
|
| 105 | 110 | fn open(&self, name: &str) -> Result<Repo, Failure> { |
|
| 106 | - | let name = RepoName::parse(name).ok_or_else(not_found)?; |
|
| 111 | + | let parsed = RepoName::parse(name).ok_or_else(not_found)?; |
|
| 112 | + | // The row knows the current name: a transferred repository still |
|
| 113 | + | // answers to its old one (#108). |
|
| 114 | + | let row = self.row(&parsed)?; |
|
| 115 | + | let name = RepoName::new(&row.owner, &row.name).ok_or_else(not_found)?; |
|
| 107 | 116 | self.repos.open(&name).map_err(|e| match e { |
|
| 108 | 117 | crate::repos::RepoError::NotFound => not_found(), |
|
| 109 | 118 | other => Failure(StatusCode::INTERNAL_SERVER_ERROR, other.to_string()), |
|
| … | 25 unchanged lines |
||
| 135 | 144 | } |
|
| 136 | 145 | oxy_server::Access::Push => token.has_scope("repo:write") || token.has_scope("repo:admin"), |
|
| 137 | 146 | }; |
|
| 138 | - | (role, needed && token.covers(&parsed.to_string())) |
|
| 147 | + | let current = format!("{}/{}", row.owner, row.name); |
|
| 148 | + | (role, needed && (token.covers(&parsed.to_string()) || token.covers(¤t))) |
|
| 139 | 149 | } |
|
| 140 | 150 | }; |
|
| 141 | 151 | // An unreadable repository does not exist for the caller (hub spec §4.4). |
|
| … | 9 unchanged lines |
||
| 151 | 161 | } |
|
| 152 | 162 | if !auth::roles::allows(role, Access::Write) { |
|
| 153 | 163 | return Err(Failure(StatusCode::FORBIDDEN, "you do not have write access to this repository".into())); |
|
| 164 | + | } |
|
| 165 | + | if row.archived { |
|
| 166 | + | return Err(Failure(StatusCode::FORBIDDEN, "this repository is archived and read-only".into())); |
|
| 154 | 167 | } |
|
| 155 | 168 | if !scope_ok { |
|
| 156 | 169 | return Err(Failure(StatusCode::FORBIDDEN, "this token lacks the repo:write scope for this repository".into())); |
|
| … | 11 unchanged lines |
||
| 168 | 181 | fn after_push(&self, name: &str, report: &PushReport, headers: &HeaderMap) { |
|
| 169 | 182 | let Some(parsed) = RepoName::parse(name) else { return }; |
|
| 170 | 183 | let Ok(row) = self.row(&parsed) else { return }; |
|
| 184 | + | let Some(parsed) = RepoName::new(&row.owner, &row.name) else { return }; |
|
| 171 | 185 | let pusher = match self.caller(headers) { |
|
| 172 | 186 | Ok(Caller::Token(account, _)) => Some((account.id, account.username)), |
|
| 173 | 187 | Ok(Caller::Site) => Some((0, "site token".to_string())), |
|
| … | 147 unchanged lines |
modified crates/hub/src/repos/mod.rs +21 −0
| … | 129 unchanged lines |
||
| 130 | 130 | .map_err(|e| RepoError::Io(std::io::Error::other(e.to_string())))? |
|
| 131 | 131 | } |
|
| 132 | 132 | ||
| 133 | + | /// Move a repository to another name (a transfer, #108): the directory, |
|
| 134 | + | /// and the lock the old name held. |
|
| 135 | + | pub fn rename(&self, from: &RepoName, to: &RepoName) -> Result<(), RepoError> { |
|
| 136 | + | let (src, dest) = (self.dir(from), self.dir(to)); |
|
| 137 | + | if !src.exists() { |
|
| 138 | + | return Err(RepoError::NotFound); |
|
| 139 | + | } |
|
| 140 | + | if dest.exists() { |
|
| 141 | + | return Err(RepoError::Exists(to.to_string())); |
|
| 142 | + | } |
|
| 143 | + | if let Some(parent) = dest.parent() { |
|
| 144 | + | std::fs::create_dir_all(parent)?; |
|
| 145 | + | } |
|
| 146 | + | std::fs::rename(&src, &dest)?; |
|
| 147 | + | let mut locks = self.locks.lock().unwrap(); |
|
| 148 | + | if let Some(l) = locks.remove(&from.to_string()) { |
|
| 149 | + | locks.insert(to.to_string(), l); |
|
| 150 | + | } |
|
| 151 | + | Ok(()) |
|
| 152 | + | } |
|
| 153 | + | ||
| 133 | 154 | /// Move a repository to the trash (hub spec §4.1); a job empties it later. |
|
| 134 | 155 | pub fn trash(&self, name: &RepoName) -> Result<PathBuf, RepoError> { |
|
| 135 | 156 | let dir = self.dir(name); |
|
| … | 92 unchanged lines |
modified crates/hub/src/web/issues.rs +2 −0
| … | 238 unchanged lines |
||
| 239 | 239 | let me = require(&viewer, &shell)?; |
|
| 240 | 240 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 241 | 241 | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 242 | + | super::writable(&row, &shell)?; |
|
| 242 | 243 | if f.title.trim().is_empty() { |
|
| 243 | 244 | return Ok(Page(NewIssuePage { |
|
| 244 | 245 | shell, |
|
| … | 572 unchanged lines |
||
| 817 | 818 | ) -> Result<Page<LabelsPage>, Error> { |
|
| 818 | 819 | let shell = Shell::new(&app, &format!("/{owner}/{name}/labels"), &viewer); |
|
| 819 | 820 | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 821 | + | super::writable(&row, &shell)?; |
|
| 820 | 822 | let labels = idb::labels(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 821 | 823 | Ok(Page(LabelsPage { |
|
| 822 | 824 | shell, |
|
| … | 226 unchanged lines |
modified crates/hub/src/web/mod.rs +39 −4
| … | 156 unchanged lines |
||
| 157 | 157 | .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update)) |
|
| 158 | 158 | .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator)) |
|
| 159 | 159 | .route("/{owner}/{name}/settings/delete", post(repo_settings::delete)) |
|
| 160 | + | .route("/{owner}/{name}/settings/transfer", post(repo_settings::transfer)) |
|
| 161 | + | .route("/{owner}/{name}/settings/archive", post(repo_settings::archive)) |
|
| 160 | 162 | .route("/{owner}/{name}/settings/requirements", post(repo_settings::requirements)) |
|
| 161 | 163 | .route("/{owner}/{name}/proposals", get(proposals::list)) |
|
| 162 | 164 | .route("/{owner}/{name}/proposals/new", get(proposals::new_form).post(proposals::create)) |
|
| … | 214 unchanged lines |
||
| 377 | 379 | /// Open issues and proposals, shown on their tabs. |
|
| 378 | 380 | pub open_issues: i64, |
|
| 379 | 381 | pub open_proposals: i64, |
|
| 382 | + | /// Read-only (#108). |
|
| 383 | + | pub archived: bool, |
|
| 380 | 384 | } |
|
| 381 | 385 | ||
| 382 | 386 | impl RepoShell { |
|
| … | 10 unchanged lines |
||
| 393 | 397 | role: None, |
|
| 394 | 398 | open_issues: row.open_issues, |
|
| 395 | 399 | open_proposals: row.open_proposals, |
|
| 400 | + | archived: row.archived, |
|
| 396 | 401 | } |
|
| 397 | 402 | } |
|
| 398 | 403 | ||
| … | 208 unchanged lines |
||
| 607 | 612 | access: Access, |
|
| 608 | 613 | ) -> Result<(crate::repos::RepoName, db::repos::RepoRow, Role), Error> { |
|
| 609 | 614 | let repo = crate::repos::RepoName::new(owner, name).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 610 | - | let row = db::repos::get(&app.db, owner, name) |
|
| 611 | - | .await |
|
| 612 | - | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 613 | - | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 615 | + | let row = match db::repos::get(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 616 | + | Some(row) => row, |
|
| 617 | + | None => { |
|
| 618 | + | // A transferred repository: the old URL goes to the new one (#108), |
|
| 619 | + | // the rest of the path kept, when the viewer may see it. |
|
| 620 | + | let moved = db::repos::moved_to(&app.db, owner, name).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 621 | + | let Some(moved) = moved else { return Err(Error::not_found(shell.clone())) }; |
|
| 622 | + | let role = auth::roles::role_for(&app.db, viewer.account(), &moved).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 623 | + | if !auth::roles::allows(role, Access::Read) { |
|
| 624 | + | return Err(Error::not_found(shell.clone())); |
|
| 625 | + | } |
|
| 626 | + | let old = format!("/{owner}/{name}"); |
|
| 627 | + | let rest = shell.path.strip_prefix(&old).unwrap_or(""); |
|
| 628 | + | return Err(Error::redirect(format!("/{}/{}{rest}", moved.owner, moved.name))); |
|
| 629 | + | } |
|
| 630 | + | }; |
|
| 614 | 631 | let role = auth::roles::role_for(&app.db, viewer.account(), &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 615 | 632 | if !auth::roles::allows(role, access) { |
|
| 616 | 633 | return Err(Error::not_found(shell.clone())); |
|
| 617 | 634 | } |
|
| 635 | + | if row.archived && matches!(access, Access::Write | Access::Maintain) { |
|
| 636 | + | return Err(archived(shell)); |
|
| 637 | + | } |
|
| 618 | 638 | Ok((repo, row, role.unwrap_or(Role::Read))) |
|
| 639 | + | } |
|
| 640 | + | ||
| 641 | + | /// The error for a write to an archived repository (#108). |
|
| 642 | + | pub fn archived(shell: &Shell) -> Error { |
|
| 643 | + | Error::bad(shell.clone(), "This repository is archived and read-only. Its admins can bring it back under Settings › Advanced.") |
|
| 644 | + | } |
|
| 645 | + | ||
| 646 | + | /// Refuse a change to an archived repository, for handlers that open it |
|
| 647 | + | /// for reading and then write. |
|
| 648 | + | pub fn writable(row: &db::repos::RepoRow, shell: &Shell) -> Result<(), Error> { |
|
| 649 | + | if row.archived { |
|
| 650 | + | Err(archived(shell)) |
|
| 651 | + | } else { |
|
| 652 | + | Ok(()) |
|
| 653 | + | } |
|
| 619 | 654 | } |
|
| 620 | 655 | ||
| 621 | 656 | /// `lookup_role` for reading. |
|
| … | 23 unchanged lines |
modified crates/hub/src/web/proposals.rs +6 −0
| … | 343 unchanged lines |
||
| 344 | 344 | let me = require(&viewer, &shell)?; |
|
| 345 | 345 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 346 | 346 | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 347 | + | super::writable(&row, &shell)?; |
|
| 347 | 348 | match open_proposal(&app, &repo, &row, &me.account, &f.title, &f.body, &f.target, &f.source, f.draft.is_some()).await { |
|
| 348 | 349 | Ok(created) => Ok(Redirect::to(&format!("/{owner}/{name}/proposal/{}", created.number)).into_response()), |
|
| 349 | 350 | Err(msg) => { |
|
| … | 547 unchanged lines |
||
| 897 | 898 | let me = require(&viewer, &shell)?; |
|
| 898 | 899 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 899 | 900 | let (repo, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 901 | + | super::writable(&row, &shell)?; |
|
| 900 | 902 | let p = pdb::get(&app.db, row.id, number) |
|
| 901 | 903 | .await |
|
| 902 | 904 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| … | 42 unchanged lines |
||
| 945 | 947 | let me = require(&viewer, &shell)?; |
|
| 946 | 948 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 947 | 949 | let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 950 | + | super::writable(&row, &shell)?; |
|
| 948 | 951 | let p = pdb::get(&app.db, row.id, number) |
|
| 949 | 952 | .await |
|
| 950 | 953 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| … | 537 unchanged lines |
||
| 1488 | 1491 | let me = require(&viewer, &shell)?; |
|
| 1489 | 1492 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1490 | 1493 | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 1494 | + | super::writable(&row, &shell)?; |
|
| 1491 | 1495 | let p = pdb::get(&app.db, row.id, number) |
|
| 1492 | 1496 | .await |
|
| 1493 | 1497 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| … | 73 unchanged lines |
||
| 1567 | 1571 | let me = require(&viewer, &shell)?; |
|
| 1568 | 1572 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1569 | 1573 | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 1574 | + | super::writable(&row, &shell)?; |
|
| 1570 | 1575 | let bookmarks = |
|
| 1571 | 1576 | app.repos.read(&repo, |r, _| Ok(r.bookmarks()?.into_iter().map(|b| b.name).collect::<Vec<_>>())).await.unwrap_or_default(); |
|
| 1572 | 1577 | let (preview, conflicted) = preview_for(&app, &repo, &f.target, &f.source).await.unwrap_or((vec![], false)); |
|
| … | 61 unchanged lines |
||
| 1634 | 1639 | let me = require(&viewer, &shell)?; |
|
| 1635 | 1640 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1636 | 1641 | let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 1642 | + | super::writable(&row, &shell)?; |
|
| 1637 | 1643 | let p = pdb::get(&app.db, row.id, number) |
|
| 1638 | 1644 | .await |
|
| 1639 | 1645 | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| … | 25 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +103 −0
| … | 284 unchanged lines |
||
| 285 | 285 | } |
|
| 286 | 286 | ||
| 287 | 287 | #[derive(Deserialize)] |
|
| 288 | + | pub struct TransferForm { |
|
| 289 | + | #[serde(rename = "_csrf")] |
|
| 290 | + | csrf_token: String, |
|
| 291 | + | owner: String, |
|
| 292 | + | confirm: String, |
|
| 293 | + | } |
|
| 294 | + | ||
| 295 | + | /// `POST /{owner}/{name}/settings/transfer`: move the repository to a |
|
| 296 | + | /// user or organisation the admin may create repositories under (#108). |
|
| 297 | + | /// The old URL redirects and oxy remotes keep working. |
|
| 298 | + | pub async fn transfer( |
|
| 299 | + | State(app): State<Shared>, |
|
| 300 | + | viewer: Viewer, |
|
| 301 | + | Path((owner, name)): Path<(String, String)>, |
|
| 302 | + | Form(f): Form<TransferForm>, |
|
| 303 | + | ) -> Result<Response, Error> { |
|
| 304 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 305 | + | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 306 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 307 | + | let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 308 | + | let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into())); |
|
| 309 | + | if f.confirm.trim() != repo.to_string() { |
|
| 310 | + | return Ok(fail("Type the repository's full name to confirm.").await?.into_response()); |
|
| 311 | + | } |
|
| 312 | + | let to = f.owner.trim().trim_start_matches('@').to_string(); |
|
| 313 | + | let Some(dest) = RepoName::new(&to, &row.name) else { |
|
| 314 | + | return Ok(fail("That is not an owner name.").await?.into_response()); |
|
| 315 | + | }; |
|
| 316 | + | if dest.owner == row.owner { |
|
| 317 | + | return Ok(fail("The repository is already there.").await?.into_response()); |
|
| 318 | + | } |
|
| 319 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 320 | + | let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some() |
|
| 321 | + | || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some(); |
|
| 322 | + | if !known { |
|
| 323 | + | return Ok(fail("There is no user or organisation of that name.").await?.into_response()); |
|
| 324 | + | } |
|
| 325 | + | if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? { |
|
| 326 | + | return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response()); |
|
| 327 | + | } |
|
| 328 | + | if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) { |
|
| 329 | + | return Ok(fail("That owner already has a repository of this name.").await?.into_response()); |
|
| 330 | + | } |
|
| 331 | + | { |
|
| 332 | + | let _guard = app.repos.write_guard(&repo).await; |
|
| 333 | + | app.repos.rename(&repo, &dest).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 334 | + | db::repos::transfer(&app.db, row.id, &row.owner, &row.name, &dest.owner).await.map_err(e)?; |
|
| 335 | + | let mirror = crate::mirror::mirror_dir(&app, &repo); |
|
| 336 | + | if mirror.is_dir() { |
|
| 337 | + | let to = crate::mirror::mirror_dir(&app, &dest); |
|
| 338 | + | if let Some(parent) = to.parent() { |
|
| 339 | + | std::fs::create_dir_all(parent).ok(); |
|
| 340 | + | } |
|
| 341 | + | std::fs::rename(&mirror, &to).ok(); |
|
| 342 | + | } |
|
| 343 | + | } |
|
| 344 | + | accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok(); |
|
| 345 | + | let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here.")); |
|
| 346 | + | Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response()) |
|
| 347 | + | } |
|
| 348 | + | ||
| 349 | + | #[derive(Deserialize)] |
|
| 350 | + | pub struct ArchiveForm { |
|
| 351 | + | #[serde(rename = "_csrf")] |
|
| 352 | + | csrf_token: String, |
|
| 353 | + | /// `archive` or `unarchive`. |
|
| 354 | + | action: String, |
|
| 355 | + | #[serde(default)] |
|
| 356 | + | reason: String, |
|
| 357 | + | } |
|
| 358 | + | ||
| 359 | + | /// `POST /{owner}/{name}/settings/archive`: read-only, with a reason, or |
|
| 360 | + | /// back again (#108). |
|
| 361 | + | pub async fn archive( |
|
| 362 | + | State(app): State<Shared>, |
|
| 363 | + | viewer: Viewer, |
|
| 364 | + | Path((owner, name)): Path<(String, String)>, |
|
| 365 | + | Form(f): Form<ArchiveForm>, |
|
| 366 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 367 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 368 | + | let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 369 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 370 | + | let archive = match f.action.as_str() { |
|
| 371 | + | "archive" => true, |
|
| 372 | + | "unarchive" => false, |
|
| 373 | + | _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await, |
|
| 374 | + | }; |
|
| 375 | + | let reason: String = f.reason.trim().chars().take(500).collect(); |
|
| 376 | + | db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 377 | + | accounts::audit(&app.db, viewer.account(), if archive { "repo.archived" } else { "repo.unarchived" }, &repo.to_string(), &reason) |
|
| 378 | + | .await |
|
| 379 | + | .ok(); |
|
| 380 | + | row.archived = archive; |
|
| 381 | + | row.archived_reason = if archive { reason } else { String::new() }; |
|
| 382 | + | let notice = if archive { |
|
| 383 | + | "The repository is archived: read-only until it is brought back." |
|
| 384 | + | } else { |
|
| 385 | + | "The repository is back: writes are allowed again." |
|
| 386 | + | }; |
|
| 387 | + | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 388 | + | } |
|
| 389 | + | ||
| 390 | + | #[derive(Deserialize)] |
|
| 288 | 391 | pub struct DeleteForm { |
|
| 289 | 392 | #[serde(rename = "_csrf")] |
|
| 290 | 393 | csrf_token: String, |
|
| … | 299 unchanged lines |
modified crates/hub/tests/edits.rs +84 −0
| … | 569 unchanged lines |
||
| 570 | 570 | assert_eq!(badge.status, StatusCode::OK); |
|
| 571 | 571 | assert!(badge.body.contains("<svg") && badge.body.contains("no runs"), "{}", badge.body); |
|
| 572 | 572 | } |
|
| 573 | + | ||
| 574 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 575 | + | async fn a_repository_is_transferred_and_archived_from_advanced_settings() { |
|
| 576 | + | hermetic(); |
|
| 577 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 578 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 579 | + | config.auth.push_tokens = vec!["site".into()]; |
|
| 580 | + | config.registration.mode = "open".into(); |
|
| 581 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 582 | + | let port = listener.local_addr().unwrap().port(); |
|
| 583 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 584 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 585 | + | let router = oxygenhub::router(app.clone()); |
|
| 586 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 587 | + | let base = format!("http://127.0.0.1:{port}"); |
|
| 588 | + | let app = &app; |
|
| 589 | + | let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await; |
|
| 590 | + | let andy = r.cookie.unwrap(); |
|
| 591 | + | let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 592 | + | db::accounts::create_org(&app.db, "doomsday", "Doomsday One", andy_id).await.unwrap(); |
|
| 593 | + | let repo = RepoName::parse("andy/vk").unwrap(); |
|
| 594 | + | app.repos.create(&repo, None).unwrap(); |
|
| 595 | + | db::repos::create(&app.db, "andy", "vk", "a port", hub_api::Visibility::Public).await.unwrap(); |
|
| 596 | + | let work = tempfile::tempdir().unwrap(); |
|
| 597 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 598 | + | client.create_identity("Andy McCall", "[email protected]", IdentityScope::Repo).unwrap(); |
|
| 599 | + | client.config.set("core.sign", "change").unwrap(); |
|
| 600 | + | client.config.set("remote.origin.url", &format!("{base}/andy/vk")).unwrap(); |
|
| 601 | + | client.config.set("remote.origin.token", "site").unwrap(); |
|
| 602 | + | client.save_config().unwrap(); |
|
| 603 | + | write(work.path(), "a.txt", b"one\n"); |
|
| 604 | + | client.snapshot("t").unwrap(); |
|
| 605 | + | client.commit("first", &[]).unwrap(); |
|
| 606 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 607 | + | ||
| 608 | + | // The Advanced section, and a transfer with the wrong confirmation. |
|
| 609 | + | let settings = send(app, "GET", "/andy/vk/settings", Some(&andy), None).await.body; |
|
| 610 | + | assert!( |
|
| 611 | + | settings.contains("Advanced") && settings.contains("Transfer ownership") && settings.contains("Delete wiki data"), |
|
| 612 | + | "{settings}" |
|
| 613 | + | ); |
|
| 614 | + | let csrf = csrf_of(&settings); |
|
| 615 | + | let r = send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=wrong"))).await; |
|
| 616 | + | assert!(r.body.contains("full name to confirm"), "{}", r.body); |
|
| 617 | + | let r = |
|
| 618 | + | send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=nobody&confirm=andy%2Fvk"))).await; |
|
| 619 | + | assert!(r.body.contains("no user or organisation"), "{}", r.body); |
|
| 620 | + | // Moved to the organisation: the new page works, the old URL redirects |
|
| 621 | + | // with the rest of its path, and the client's old remote still pushes. |
|
| 622 | + | let r = |
|
| 623 | + | send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=andy%2Fvk"))).await; |
|
| 624 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 625 | + | let page = send(app, "GET", "/doomsday/vk", None, None).await; |
|
| 626 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 627 | + | assert!(page.body.contains("a port"), "{}", page.body); |
|
| 628 | + | let old = send(app, "GET", "/andy/vk/changes/main", None, None).await; |
|
| 629 | + | assert_eq!(old.status, StatusCode::SEE_OTHER, "{}", old.body); |
|
| 630 | + | assert!(app.repos.exists(&RepoName::parse("doomsday/vk").unwrap()) && !app.repos.exists(&repo)); |
|
| 631 | + | write(work.path(), "a.txt", b"one\ntwo\n"); |
|
| 632 | + | client.snapshot("t").unwrap(); |
|
| 633 | + | client.commit("second", &[]).unwrap(); |
|
| 634 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok), "the old remote address still resolves"); |
|
| 635 | + | let audit = db::accounts::audit_rows(&app.db, 10).await.unwrap(); |
|
| 636 | + | assert!(audit.iter().any(|a| a.action == "repo.transferred" && a.target == "andy/vk" && a.detail == "doomsday/vk")); |
|
| 637 | + | ||
| 638 | + | // Archived: pushes, issues and comments are refused; reading works; back again. |
|
| 639 | + | let settings = send(app, "GET", "/doomsday/vk/settings", Some(&andy), None).await.body; |
|
| 640 | + | let csrf = csrf_of(&settings); |
|
| 641 | + | let r = |
|
| 642 | + | send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=archive&reason=Superseded"))) |
|
| 643 | + | .await; |
|
| 644 | + | assert!(r.body.contains("is archived") && r.body.contains("Bring this repository back") && r.body.contains("Superseded"), "{}", r.body); |
|
| 645 | + | assert!(send(app, "GET", "/doomsday/vk", None, None).await.body.contains(">archived<")); |
|
| 646 | + | write(work.path(), "a.txt", b"one\ntwo\nthree\n"); |
|
| 647 | + | client.snapshot("t").unwrap(); |
|
| 648 | + | client.commit("third", &[]).unwrap(); |
|
| 649 | + | let pushed = client.push("origin", false); |
|
| 650 | + | assert!(pushed.is_err() || !pushed.unwrap().iter().all(|r| r.ok), "an archived repository takes no pushes"); |
|
| 651 | + | let r = send(app, "POST", "/doomsday/vk/issues/new", Some(&andy), Some(&format!("_csrf={csrf}&title=x&body=y"))).await; |
|
| 652 | + | assert!(r.body.contains("archived and read-only"), "{} {}", r.status, r.body); |
|
| 653 | + | let r = send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=unarchive"))).await; |
|
| 654 | + | assert!(r.body.contains("The repository is back"), "{}", r.body); |
|
| 655 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 656 | + | } |
modified docs/spec.md +1 −1
| … | 139 unchanged lines |
||
| 140 | 140 | ||
| 141 | 141 | Creation: `Repo::init` under `<data>/repos/<owner>/<name>` with a fresh repository id, then an initial row in `repositories`. Import: a client `oxy push` into an empty hub repository; or, for Git, §14. |
|
| 142 | 142 | ||
| 143 | - | Deletion moves the directory to `<data>/trash/<owner>/<name>-<timestamp>` and a job removes it after `trash_days` (default 30). Renames are a directory move under the repository's write lock plus a `redirects` row so old URLs resolve for 90 days. |
|
| 143 | + | Deletion moves the directory to `<data>/trash/<owner>/<name>-<timestamp>` and a job removes it after `trash_days` (default 30). **Transfer** (oxygenhub#108, Settings › Advanced, the repository's admins) moves it to a user or organisation the admin may create repositories under, keeping its name: a directory move under the repository's write lock (the Git mirror moves with it), the row's owner, and a `repo_redirects` row so the old address redirects on the web (the rest of the path kept) and still resolves for `oxy` remotes and the API. **Archive** marks it read-only with a reason: pushes are refused by the wire protocol, and the web refuses edits, issues, proposals, comments and reviews, while reading, cloning and the admins' settings go on; the header shows an *archived* badge and the admins bring it back from the same place. Both are audited (`repo.transferred`, `repo.archived`, `repo.unarchived`). The **Advanced** section also holds Delete and, ahead of wikis (oxygenhub#104), a disabled Delete wiki data. |
|
| 144 | 144 | ||
| 145 | 145 | ### 4.2 Forks |
|
| 146 | 146 | ||
| … | 562 unchanged lines |
modified templates/repo_settings.html +41 −10
| … | 22 unchanged lines |
||
| 23 | 23 | <a href="#secrets">Secrets</a> |
|
| 24 | 24 | <a href="#assistants">AI assistants</a> |
|
| 25 | 25 | <a href="#collaborators">Collaborators</a> |
|
| 26 | - | <a href="#danger">Delete</a>{% endif %} |
|
| 26 | + | <a href="#advanced">Advanced</a>{% endif %} |
|
| 27 | 27 | </nav> |
|
| 28 | 28 | <div class="sections"> |
|
| 29 | 29 | <form class="card wide" id="general" method="post" action="{{ repo.url }}/settings"> |
|
| … | 87 unchanged lines |
||
| 117 | 117 | <button type="submit">ADD</button> |
|
| 118 | 118 | </form> |
|
| 119 | 119 | </section> |
|
| 120 | - | <details class="danger" id="danger"> |
|
| 121 | - | <summary>Delete this repository</summary> |
|
| 122 | - | <form class="card" method="post" action="{{ repo.url }}/settings/delete"> |
|
| 123 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 124 | - | <p class="small">The repository moves to the trash and is removed after the retention period. Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm.</p> |
|
| 125 | - | <input type="text" name="confirm" required> |
|
| 126 | - | <button type="submit">DELETE</button> |
|
| 127 | - | </form> |
|
| 128 | - | </details> |
|
| 120 | + | <section class="panel danger-zone" id="advanced"> |
|
| 121 | + | <h2>Advanced</h2> |
|
| 122 | + | <p class="small mute">Settings that change what this repository is. Each asks you to confirm; none can be undone from here except archiving.</p> |
|
| 123 | + | <div class="danger-row"> |
|
| 124 | + | <div><strong>Transfer ownership</strong><p class="small mute">Move this repository to a user or an organisation you may create repositories under. Its address changes to <code>/OWNER/{{ repo.name }}</code>; the old address redirects, and <code>oxy</code> remotes keep working.</p></div> |
|
| 125 | + | <details class="danger-act"><summary class="danger-btn">Transfer</summary> |
|
| 126 | + | <form class="card" method="post" action="{{ repo.url }}/settings/transfer"> |
|
| 127 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 128 | + | <label for="towner">New owner</label><input type="text" id="towner" name="owner" placeholder="an organisation or a user" required> |
|
| 129 | + | <label for="tconfirm">Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm</label><input type="text" id="tconfirm" name="confirm" required autocomplete="off"> |
|
| 130 | + | <button type="submit">TRANSFER</button> |
|
| 131 | + | </form> |
|
| 132 | + | </details> |
|
| 133 | + | </div> |
|
| 134 | + | <div class="danger-row"> |
|
| 135 | + | <div><strong>{% if row.archived %}Bring this repository back{% else %}Archive this repository{% endif %}</strong><p class="small mute">{% if row.archived %}It is archived: read-only for everyone, shown with a badge.{% if !row.archived_reason.is_empty() %} Reason: {{ row.archived_reason }}{% endif %}{% else %}Read-only for everyone, including you: no pushes, edits, issues, proposals or comments, until an admin brings it back. Say why, for whoever finds it later.{% endif %}</p></div> |
|
| 136 | + | <details class="danger-act"><summary class="danger-btn">{% if row.archived %}Unarchive{% else %}Archive{% endif %}</summary> |
|
| 137 | + | <form class="card" method="post" action="{{ repo.url }}/settings/archive"> |
|
| 138 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 139 | + | {% if row.archived %}<input type="hidden" name="action" value="unarchive"><button type="submit">BRING IT BACK</button>{% else %}<input type="hidden" name="action" value="archive"> |
|
| 140 | + | <label for="areason">Reason (optional)</label><textarea id="areason" name="reason" rows="2" maxlength="500" placeholder="Superseded by …"></textarea> |
|
| 141 | + | <button type="submit">ARCHIVE</button>{% endif %} |
|
| 142 | + | </form> |
|
| 143 | + | </details> |
|
| 144 | + | </div> |
|
| 145 | + | <div class="danger-row"> |
|
| 146 | + | <div><strong>Delete wiki data</strong><p class="small mute">Wikis are not here yet (oxygenhub#104). Once they are, this removes this repository's wiki, which cannot be undone.</p></div> |
|
| 147 | + | <button class="danger-btn" type="button" disabled title="Wikis are not here yet">Delete wiki data</button> |
|
| 148 | + | </div> |
|
| 149 | + | <div class="danger-row"> |
|
| 150 | + | <div><strong>Delete this repository</strong><p class="small mute">The repository moves to the trash and is removed after the retention period.</p></div> |
|
| 151 | + | <details class="danger-act"><summary class="danger-btn">Delete</summary> |
|
| 152 | + | <form class="card" method="post" action="{{ repo.url }}/settings/delete"> |
|
| 153 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 154 | + | <label for="dconfirm">Type <code>{{ repo.owner }}/{{ repo.name }}</code> to confirm</label><input type="text" id="dconfirm" name="confirm" required autocomplete="off"> |
|
| 155 | + | <button type="submit">DELETE</button> |
|
| 156 | + | </form> |
|
| 157 | + | </details> |
|
| 158 | + | </div> |
|
| 159 | + | </section> |
|
| 129 | 160 | {% endif %} |
|
| 130 | 161 | </div> |
|
| 131 | 162 | </div> |
|
| … | 1 unchanged lines |
modified templates/repo_tabs.html +1 −1
| 1 | 1 | {% macro tabs(repo, tab) %} |
|
| 2 | 2 | <nav class="tabs" aria-label="Repository"> |
|
| 3 | 3 | <div class="tabs-in"> |
|
| 4 | - | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a> <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span></span> |
|
| 4 | + | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span></span> |
|
| 5 | 5 | <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a> |
|
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a> |
|
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a> |
|
| … | 67 unchanged lines |
modified templates/style.css +10 −0
| … | 300 unchanged lines |
||
| 301 | 301 | .note { color: var(--ok); } |
|
| 302 | 302 | .error { color: var(--bad); } |
|
| 303 | 303 | details.danger { border: 1px solid var(--bad); padding: 10px 14px; margin-top: 18px; } |
|
| 304 | + | .panel.danger-zone { border-color: var(--bad); } |
|
| 305 | + | .danger-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: 10px 0; border-top: 1px solid var(--line); } |
|
| 306 | + | .danger-row:first-of-type { border-top: 0; } |
|
| 307 | + | .danger-row > div { flex: 1; min-width: 0; } |
|
| 308 | + | .danger-row p { margin: 2px 0 0; } |
|
| 309 | + | .danger-row details.danger-act { flex: 0 0 auto; max-width: 420px; } |
|
| 310 | + | .danger-row details.danger-act summary { list-style: none; display: inline-block; cursor: pointer; border: 1px solid var(--bad); color: var(--bad); padding: 6px 12px; font-weight: 600; } |
|
| 311 | + | .danger-row details.danger-act summary::-webkit-details-marker { display: none; } |
|
| 312 | + | .danger-row details.danger-act[open] { flex: 1; } |
|
| 313 | + | .danger-row details.danger-act form.card { margin-top: 8px; } |
|
| 304 | 314 | details.danger summary { color: var(--bad); cursor: pointer; font-weight: 600; } |
|
| 305 | 315 | .secret { font-family: ui-monospace, monospace; background: var(--bg); border: 1px solid var(--acc); padding: 8px 10px; word-break: break-all; } |
|
| 306 | 316 | .qr { background: #fff; display: inline-block; padding: 8px; } |
|
| … | 481 unchanged lines |