Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +63 −4
| @@ -279,6 +279,35 @@ body.drawer-open .drawer-backdrop { | |||
| 279 | margin-top: 0; | 279 | margin-top: 0; |
| 280 | } | 280 | } |
| 281 | 281 | ||
| 282 | /* Collaborators: a bordered list inside the card, then an add row. */ | ||
| 283 | .collab-list { | ||
| 284 | margin-bottom: 1rem; | ||
| 285 | } | ||
| 286 | .collab-list .entry-row { | ||
| 287 | padding-left: 0; | ||
| 288 | padding-right: 0; | ||
| 289 | } | ||
| 290 | .collab-list .entry-row-actions .btn { | ||
| 291 | padding: 0.25rem 0.6rem; | ||
| 292 | font-size: 0.85em; | ||
| 293 | } | ||
| 294 | .collab-add { | ||
| 295 | display: flex; | ||
| 296 | gap: 0.5rem; | ||
| 297 | flex-wrap: wrap; | ||
| 298 | align-items: center; | ||
| 299 | } | ||
| 300 | .collab-add input[type="text"] { | ||
| 301 | flex: 1; | ||
| 302 | min-width: 12rem; | ||
| 303 | } | ||
| 304 | .collab-add select { | ||
| 305 | width: auto; | ||
| 306 | } | ||
| 307 | .collab-add button { | ||
| 308 | flex: none; | ||
| 309 | } | ||
| 310 | |||
| 282 | label { | 311 | label { |
| 283 | display: block; | 312 | display: block; |
| 284 | margin: 0.75rem 0 0.25rem; | 313 | margin: 0.75rem 0 0.25rem; |
| @@ -314,13 +343,23 @@ select:focus { | |||
| 314 | margin: 0.25rem 0 0.5rem; | 343 | margin: 0.25rem 0 0.5rem; |
| 315 | font-size: 0.85em; | 344 | font-size: 0.85em; |
| 316 | } | 345 | } |
| 346 | /* Link slots stack full-width; the Add button sits beside the last one. */ | ||
| 347 | .link-fields { | ||
| 348 | display: flex; | ||
| 349 | flex-wrap: wrap; | ||
| 350 | align-items: center; | ||
| 351 | gap: 0.5rem; | ||
| 352 | } | ||
| 317 | .link-slot { | 353 | .link-slot { |
| 318 | margin-bottom: 0.5rem; | 354 | flex: 1 1 100%; |
| 355 | } | ||
| 356 | .link-slot.last-visible { | ||
| 357 | flex: 1 1 auto; | ||
| 319 | } | 358 | } |
| 320 | .link-add { | 359 | .link-add { |
| 321 | margin-top: 0.25rem; | 360 | flex: 0 0 auto; |
| 322 | } | 361 | } |
| 323 | /* Without JS every slot shows; the enhancement hides empties behind Add link. */ | 362 | /* Without JS every slot shows; the enhancement hides empties behind Add. */ |
| 324 | [hidden] { | 363 | [hidden] { |
| 325 | display: none !important; | 364 | display: none !important; |
| 326 | } | 365 | } |
| @@ -708,10 +747,30 @@ select:focus { | |||
| 708 | gap: 1.25rem; | 747 | gap: 1.25rem; |
| 709 | flex-wrap: wrap; | 748 | flex-wrap: wrap; |
| 710 | } | 749 | } |
| 711 | .avatar-settings form { | 750 | .avatar-form { |
| 712 | flex: 1; | 751 | flex: 1; |
| 713 | min-width: 15rem; | 752 | min-width: 15rem; |
| 714 | } | 753 | } |
| 754 | .avatar-upload-row { | ||
| 755 | display: flex; | ||
| 756 | align-items: center; | ||
| 757 | gap: 0.75rem; | ||
| 758 | flex-wrap: wrap; | ||
| 759 | margin-top: 0.6rem; | ||
| 760 | } | ||
| 761 | .avatar-form input[type="file"] { | ||
| 762 | flex: 1; | ||
| 763 | min-width: 12rem; | ||
| 764 | padding: 0.4rem; | ||
| 765 | background: var(--fb-bg-inset); | ||
| 766 | border: 1px solid var(--fb-border); | ||
| 767 | border-radius: var(--fb-radius); | ||
| 768 | color: var(--fb-fg); | ||
| 769 | } | ||
| 770 | .avatar-upload-row button[type="submit"] { | ||
| 771 | margin-top: 0; | ||
| 772 | flex: none; | ||
| 773 | } | ||
| 715 | 774 | ||
| 716 | /* ---- Search hits ---- */ | 775 | /* ---- Search hits ---- */ |
| 717 | .search-hit { | 776 | .search-hit { |
assets/fabrica.js +13 −5
| @@ -62,7 +62,8 @@ | |||
| 62 | document.querySelectorAll("[data-link-fields]").forEach(function (wrap) { | 62 | document.querySelectorAll("[data-link-fields]").forEach(function (wrap) { |
| 63 | var slots = Array.prototype.slice.call(wrap.querySelectorAll(".link-slot")); | 63 | var slots = Array.prototype.slice.call(wrap.querySelectorAll(".link-slot")); |
| 64 | var addBtn = wrap.querySelector("[data-link-add]"); | 64 | var addBtn = wrap.querySelector("[data-link-add]"); |
| 65 | var firstEmptyShown; | 65 | // Initial layout: show every filled slot plus one empty one, hide the rest. |
| 66 | var firstEmptyShown = false; | ||
| 66 | slots.forEach(function (s) { | 67 | slots.forEach(function (s) { |
| 67 | if (s.value.trim()) { | 68 | if (s.value.trim()) { |
| 68 | s.hidden = false; | 69 | s.hidden = false; |
| @@ -73,20 +74,27 @@ | |||
| 73 | s.hidden = true; | 74 | s.hidden = true; |
| 74 | } | 75 | } |
| 75 | }); | 76 | }); |
| 76 | function anyHidden() { | 77 | // Keep the Add button beside the last visible slot; hide it once all show. |
| 77 | return slots.some(function (s) { return s.hidden; }); | 78 | function mark() { |
| 79 | var lastVisible = null; | ||
| 80 | slots.forEach(function (s) { | ||
| 81 | s.classList.remove("last-visible"); | ||
| 82 | if (!s.hidden) lastVisible = s; | ||
| 83 | }); | ||
| 84 | if (lastVisible) lastVisible.classList.add("last-visible"); | ||
| 85 | if (addBtn) addBtn.hidden = !slots.some(function (s) { return s.hidden; }); | ||
| 78 | } | 86 | } |
| 79 | if (addBtn) { | 87 | if (addBtn) { |
| 80 | addBtn.hidden = !anyHidden(); | ||
| 81 | addBtn.addEventListener("click", function () { | 88 | addBtn.addEventListener("click", function () { |
| 82 | var next = slots.filter(function (s) { return s.hidden; })[0]; | 89 | var next = slots.filter(function (s) { return s.hidden; })[0]; |
| 83 | if (next) { | 90 | if (next) { |
| 84 | next.hidden = false; | 91 | next.hidden = false; |
| 85 | next.focus(); | 92 | next.focus(); |
| 86 | } | 93 | } |
| 87 | addBtn.hidden = !anyHidden(); | 94 | mark(); |
| 88 | }); | 95 | }); |
| 89 | } | 96 | } |
| 97 | mark(); | ||
| 90 | }); | 98 | }); |
| 91 | 99 | ||
| 92 | // ---- Clipboard buttons ---- | 100 | // ---- Clipboard buttons ---- |
crates/cli/src/repo_cmd.rs +137 −0
| @@ -85,6 +85,12 @@ pub(crate) enum RepoCommand { | |||
| 85 | #[arg(long)] | 85 | #[arg(long)] |
| 86 | public: bool, | 86 | public: bool, |
| 87 | }, | 87 | }, |
| 88 | /// Manage a repository's collaborators. | ||
| 89 | Collaborator { | ||
| 90 | /// The collaborator action. | ||
| 91 | #[command(subcommand)] | ||
| 92 | command: CollaboratorCommand, | ||
| 93 | }, | ||
| 88 | /// Print the resolved on-disk directory of a repository. | 94 | /// Print the resolved on-disk directory of a repository. |
| 89 | Path { | 95 | Path { |
| 90 | /// The owning user. | 96 | /// The owning user. |
| @@ -94,6 +100,39 @@ pub(crate) enum RepoCommand { | |||
| 94 | }, | 100 | }, |
| 95 | } | 101 | } |
| 96 | 102 | ||
| 103 | /// Actions under `fabrica repo collaborator`. | ||
| 104 | #[derive(Debug, Subcommand)] | ||
| 105 | pub(crate) enum CollaboratorCommand { | ||
| 106 | /// Add or update a collaborator's permission. | ||
| 107 | Add { | ||
| 108 | /// The owning user. | ||
| 109 | user: String, | ||
| 110 | /// The repo name (group path included). | ||
| 111 | name: String, | ||
| 112 | /// The collaborator's username. | ||
| 113 | collaborator: String, | ||
| 114 | /// Permission to grant: read | write | admin. | ||
| 115 | #[arg(long, default_value = "write")] | ||
| 116 | permission: String, | ||
| 117 | }, | ||
| 118 | /// Remove a collaborator. | ||
| 119 | Rm { | ||
| 120 | /// The owning user. | ||
| 121 | user: String, | ||
| 122 | /// The repo name (group path included). | ||
| 123 | name: String, | ||
| 124 | /// The collaborator's username. | ||
| 125 | collaborator: String, | ||
| 126 | }, | ||
| 127 | /// List a repository's collaborators. | ||
| 128 | List { | ||
| 129 | /// The owning user. | ||
| 130 | user: String, | ||
| 131 | /// The repo name (group path included). | ||
| 132 | name: String, | ||
| 133 | }, | ||
| 134 | } | ||
| 135 | |||
| 97 | /// Dispatch a `repo` subcommand. | 136 | /// Dispatch a `repo` subcommand. |
| 98 | pub(crate) fn run( | 137 | pub(crate) fn run( |
| 99 | command: &RepoCommand, | 138 | command: &RepoCommand, |
| @@ -141,6 +180,28 @@ pub(crate) fn run( | |||
| 141 | name, | 180 | name, |
| 142 | (*private, *internal, *public), | 181 | (*private, *internal, *public), |
| 143 | )), | 182 | )), |
| 183 | RepoCommand::Collaborator { command } => match command { | ||
| 184 | CollaboratorCommand::Add { | ||
| 185 | user, | ||
| 186 | name, | ||
| 187 | collaborator, | ||
| 188 | permission, | ||
| 189 | } => block_on(collab_add( | ||
| 190 | config_path, | ||
| 191 | user, | ||
| 192 | name, | ||
| 193 | collaborator, | ||
| 194 | permission, | ||
| 195 | )), | ||
| 196 | CollaboratorCommand::Rm { | ||
| 197 | user, | ||
| 198 | name, | ||
| 199 | collaborator, | ||
| 200 | } => block_on(collab_rm(config_path, user, name, collaborator)), | ||
| 201 | CollaboratorCommand::List { user, name } => { | ||
| 202 | block_on(collab_list(config_path, user, name, json)) | ||
| 203 | } | ||
| 204 | }, | ||
| 144 | RepoCommand::Path { user, name } => block_on(path(config_path, user, name)), | 205 | RepoCommand::Path { user, name } => block_on(path(config_path, user, name)), |
| 145 | } | 206 | } |
| 146 | } | 207 | } |
| @@ -379,6 +440,82 @@ async fn visibility( | |||
| 379 | Ok(ExitCode::SUCCESS) | 440 | Ok(ExitCode::SUCCESS) |
| 380 | } | 441 | } |
| 381 | 442 | ||
| 443 | async fn collab_add( | ||
| 444 | config_path: Option<&Path>, | ||
| 445 | user: &str, | ||
| 446 | name: &str, | ||
| 447 | collaborator: &str, | ||
| 448 | permission: &str, | ||
| 449 | ) -> anyhow::Result<ExitCode> { | ||
| 450 | if !matches!(permission, "read" | "write" | "admin") { | ||
| 451 | anyhow::bail!("permission must be read, write, or admin"); | ||
| 452 | } | ||
| 453 | let (_config, store) = open_store(config_path).await?; | ||
| 454 | let owner_id = require_user_id(&store, user).await?; | ||
| 455 | let repo = require_repo(&store, &owner_id, name, user).await?; | ||
| 456 | let target_id = require_user_id(&store, collaborator).await?; | ||
| 457 | if target_id == owner_id { | ||
| 458 | anyhow::bail!("the owner already has full access; not adding them as a collaborator"); | ||
| 459 | } | ||
| 460 | store | ||
| 461 | .set_collaborator(&repo.id, &target_id, permission) | ||
| 462 | .await?; | ||
| 463 | println!( | ||
| 464 | "{collaborator} is now a {permission} collaborator on {user}/{}", | ||
| 465 | repo.path | ||
| 466 | ); | ||
| 467 | Ok(ExitCode::SUCCESS) | ||
| 468 | } | ||
| 469 | |||
| 470 | async fn collab_rm( | ||
| 471 | config_path: Option<&Path>, | ||
| 472 | user: &str, | ||
| 473 | name: &str, | ||
| 474 | collaborator: &str, | ||
| 475 | ) -> anyhow::Result<ExitCode> { | ||
| 476 | let (_config, store) = open_store(config_path).await?; | ||
| 477 | let owner_id = require_user_id(&store, user).await?; | ||
| 478 | let repo = require_repo(&store, &owner_id, name, user).await?; | ||
| 479 | let target_id = require_user_id(&store, collaborator).await?; | ||
| 480 | if store.remove_collaborator(&repo.id, &target_id).await? { | ||
| 481 | println!("removed {collaborator} from {user}/{}", repo.path); | ||
| 482 | } else { | ||
| 483 | println!( | ||
| 484 | "{collaborator} was not a collaborator on {user}/{}", | ||
| 485 | repo.path | ||
| 486 | ); | ||
| 487 | } | ||
| 488 | Ok(ExitCode::SUCCESS) | ||
| 489 | } | ||
| 490 | |||
| 491 | async fn collab_list( | ||
| 492 | config_path: Option<&Path>, | ||
| 493 | user: &str, | ||
| 494 | name: &str, | ||
| 495 | json: bool, | ||
| 496 | ) -> anyhow::Result<ExitCode> { | ||
| 497 | let (_config, store) = open_store(config_path).await?; | ||
| 498 | let owner_id = require_user_id(&store, user).await?; | ||
| 499 | let repo = require_repo(&store, &owner_id, name, user).await?; | ||
| 500 | let collaborators = store.list_collaborators(&repo.id).await?; | ||
| 501 | if json { | ||
| 502 | let view: Vec<_> = collaborators | ||
| 503 | .iter() | ||
| 504 | .map(|c| serde_json::json!({ "username": c.username, "permission": c.permission })) | ||
| 505 | .collect(); | ||
| 506 | println!("{}", serde_json::to_string_pretty(&view)?); | ||
| 507 | return Ok(ExitCode::SUCCESS); | ||
| 508 | } | ||
| 509 | if collaborators.is_empty() { | ||
| 510 | println!("no collaborators"); | ||
| 511 | return Ok(ExitCode::SUCCESS); | ||
| 512 | } | ||
| 513 | for c in &collaborators { | ||
| 514 | println!("{} [{}]", c.username, c.permission); | ||
| 515 | } | ||
| 516 | Ok(ExitCode::SUCCESS) | ||
| 517 | } | ||
| 518 | |||
| 382 | async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> { | 519 | async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> { |
| 383 | let (config, store) = open_store(config_path).await?; | 520 | let (config, store) = open_store(config_path).await?; |
| 384 | let owner_id = require_user_id(&store, user).await?; | 521 | let owner_id = require_user_id(&store, user).await?; |
crates/store/src/lib.rs +1 −1
| @@ -43,7 +43,7 @@ use sqlx::migrate::Migrator; | |||
| 43 | 43 | ||
| 44 | pub use crate::invites::NewInvite; | 44 | pub use crate::invites::NewInvite; |
| 45 | pub use crate::keys::NewKey; | 45 | pub use crate::keys::NewKey; |
| 46 | pub use crate::repos::NewRepo; | 46 | pub use crate::repos::{Collaborator, NewRepo}; |
| 47 | pub use crate::sessions::NewSession; | 47 | pub use crate::sessions::NewSession; |
| 48 | pub use crate::tokens::NewToken; | 48 | pub use crate::tokens::NewToken; |
| 49 | pub use crate::users::{NewUser, ProfileUpdate}; | 49 | pub use crate::users::{NewUser, ProfileUpdate}; |
crates/store/src/repos.rs +79 −0
| @@ -34,6 +34,17 @@ pub struct NewRepo { | |||
| 34 | pub default_branch: String, | 34 | pub default_branch: String, |
| 35 | } | 35 | } |
| 36 | 36 | ||
| 37 | /// A repo collaborator with their username resolved, for listings. | ||
| 38 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 39 | pub struct Collaborator { | ||
| 40 | /// The collaborator's user id. | ||
| 41 | pub user_id: String, | ||
| 42 | /// Their username. | ||
| 43 | pub username: String, | ||
| 44 | /// Their permission (`read` | `write` | `admin`). | ||
| 45 | pub permission: String, | ||
| 46 | } | ||
| 47 | |||
| 37 | impl Store { | 48 | impl Store { |
| 38 | /// Create a repository, returning the persisted row. | 49 | /// Create a repository, returning the persisted row. |
| 39 | /// | 50 | /// |
| @@ -261,6 +272,74 @@ impl Store { | |||
| 261 | Ok(row.as_ref().map(|r| r.try_get("permission")).transpose()?) | 272 | Ok(row.as_ref().map(|r| r.try_get("permission")).transpose()?) |
| 262 | } | 273 | } |
| 263 | 274 | ||
| 275 | /// List a repo's explicit collaborators, alphabetical by username. | ||
| 276 | /// | ||
| 277 | /// # Errors | ||
| 278 | /// | ||
| 279 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 280 | pub async fn list_collaborators(&self, repo_id: &str) -> Result<Vec<Collaborator>, StoreError> { | ||
| 281 | let sql = "SELECT c.user_id, u.username, c.permission \ | ||
| 282 | FROM repo_collaborators c JOIN users u ON u.id = c.user_id \ | ||
| 283 | WHERE c.repo_id = $1 ORDER BY u.username_lower ASC"; | ||
| 284 | let rows = sqlx::query(sql).bind(repo_id).fetch_all(&self.pool).await?; | ||
| 285 | rows.iter() | ||
| 286 | .map(|r| { | ||
| 287 | Ok(Collaborator { | ||
| 288 | user_id: r.try_get("user_id")?, | ||
| 289 | username: r.try_get("username")?, | ||
| 290 | permission: r.try_get("permission")?, | ||
| 291 | }) | ||
| 292 | }) | ||
| 293 | .collect::<Result<_, sqlx::Error>>() | ||
| 294 | .map_err(Into::into) | ||
| 295 | } | ||
| 296 | |||
| 297 | /// Add or update a collaborator's permission on a repo (`read`|`write`| | ||
| 298 | /// `admin`). The caller validates the permission token. | ||
| 299 | /// | ||
| 300 | /// # Errors | ||
| 301 | /// | ||
| 302 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 303 | pub async fn set_collaborator( | ||
| 304 | &self, | ||
| 305 | repo_id: &str, | ||
| 306 | user_id: &str, | ||
| 307 | permission: &str, | ||
| 308 | ) -> Result<(), StoreError> { | ||
| 309 | // Portable upsert: both dialects support ON CONFLICT DO UPDATE. | ||
| 310 | let sql = "INSERT INTO repo_collaborators (repo_id, user_id, permission, created_at) \ | ||
| 311 | VALUES ($1, $2, $3, $4) \ | ||
| 312 | ON CONFLICT (repo_id, user_id) DO UPDATE SET permission = $3"; | ||
| 313 | sqlx::query(sql) | ||
| 314 | .bind(repo_id) | ||
| 315 | .bind(user_id) | ||
| 316 | .bind(permission) | ||
| 317 | .bind(now_ms()) | ||
| 318 | .execute(&self.pool) | ||
| 319 | .await?; | ||
| 320 | Ok(()) | ||
| 321 | } | ||
| 322 | |||
| 323 | /// Remove a collaborator from a repo. Returns `true` if a row was removed. | ||
| 324 | /// | ||
| 325 | /// # Errors | ||
| 326 | /// | ||
| 327 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 328 | pub async fn remove_collaborator( | ||
| 329 | &self, | ||
| 330 | repo_id: &str, | ||
| 331 | user_id: &str, | ||
| 332 | ) -> Result<bool, StoreError> { | ||
| 333 | let deleted = | ||
| 334 | sqlx::query("DELETE FROM repo_collaborators WHERE repo_id = $1 AND user_id = $2") | ||
| 335 | .bind(repo_id) | ||
| 336 | .bind(user_id) | ||
| 337 | .execute(&self.pool) | ||
| 338 | .await? | ||
| 339 | .rows_affected(); | ||
| 340 | Ok(deleted > 0) | ||
| 341 | } | ||
| 342 | |||
| 264 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. | 343 | /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`]. |
| 265 | // A method (not an associated fn) for symmetry with the other row mappers, | 344 | // A method (not an associated fn) for symmetry with the other row mappers, |
| 266 | // even though the visibility mapping no longer needs `self`. | 345 | // even though the visibility mapping no longer needs `self`. |
crates/store/src/tests.rs +75 −0
| @@ -146,6 +146,81 @@ async fn update_profile_and_avatar_round_trip() { | |||
| 146 | } | 146 | } |
| 147 | 147 | ||
| 148 | #[tokio::test] | 148 | #[tokio::test] |
| 149 | async fn collaborators_add_update_list_remove() { | ||
| 150 | for fx in sqlite_fixtures().await { | ||
| 151 | let owner = fx | ||
| 152 | .store | ||
| 153 | .create_user(sample_user("owner", "owner@example.com")) | ||
| 154 | .await | ||
| 155 | .unwrap(); | ||
| 156 | let friend = fx | ||
| 157 | .store | ||
| 158 | .create_user(sample_user("friend", "friend@example.com")) | ||
| 159 | .await | ||
| 160 | .unwrap(); | ||
| 161 | let repo = fx | ||
| 162 | .store | ||
| 163 | .create_repo(NewRepo { | ||
| 164 | owner_id: owner.id.clone(), | ||
| 165 | group_id: None, | ||
| 166 | name: "proj".to_string(), | ||
| 167 | path: "proj".to_string(), | ||
| 168 | description: None, | ||
| 169 | visibility: model::Visibility::Private, | ||
| 170 | default_branch: "main".to_string(), | ||
| 171 | }) | ||
| 172 | .await | ||
| 173 | .unwrap(); | ||
| 174 | |||
| 175 | fx.store | ||
| 176 | .set_collaborator(&repo.id, &friend.id, "read") | ||
| 177 | .await | ||
| 178 | .unwrap(); | ||
| 179 | let list = fx.store.list_collaborators(&repo.id).await.unwrap(); | ||
| 180 | assert_eq!(list.len(), 1); | ||
| 181 | assert_eq!(list[0].username, "friend"); | ||
| 182 | assert_eq!(list[0].permission, "read"); | ||
| 183 | |||
| 184 | // Upsert changes the permission in place. | ||
| 185 | fx.store | ||
| 186 | .set_collaborator(&repo.id, &friend.id, "write") | ||
| 187 | .await | ||
| 188 | .unwrap(); | ||
| 189 | let list = fx.store.list_collaborators(&repo.id).await.unwrap(); | ||
| 190 | assert_eq!(list.len(), 1, "upsert, not a second row"); | ||
| 191 | assert_eq!(list[0].permission, "write"); | ||
| 192 | assert_eq!( | ||
| 193 | fx.store | ||
| 194 | .collaborator_permission(&repo.id, &friend.id) | ||
| 195 | .await | ||
| 196 | .unwrap() | ||
| 197 | .as_deref(), | ||
| 198 | Some("write") | ||
| 199 | ); | ||
| 200 | |||
| 201 | assert!( | ||
| 202 | fx.store | ||
| 203 | .remove_collaborator(&repo.id, &friend.id) | ||
| 204 | .await | ||
| 205 | .unwrap() | ||
| 206 | ); | ||
| 207 | assert!( | ||
| 208 | fx.store | ||
| 209 | .list_collaborators(&repo.id) | ||
| 210 | .await | ||
| 211 | .unwrap() | ||
| 212 | .is_empty() | ||
| 213 | ); | ||
| 214 | assert!( | ||
| 215 | !fx.store | ||
| 216 | .remove_collaborator(&repo.id, &friend.id) | ||
| 217 | .await | ||
| 218 | .unwrap() | ||
| 219 | ); | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | #[tokio::test] | ||
| 149 | async fn duplicate_username_conflicts_case_insensitively() { | 224 | async fn duplicate_username_conflicts_case_insensitively() { |
| 150 | for fx in sqlite_fixtures().await { | 225 | for fx in sqlite_fixtures().await { |
| 151 | fx.store | 226 | fx.store |
crates/web/src/lib.rs +8 −0
| @@ -149,6 +149,14 @@ pub fn build_router(state: AppState) -> Router { | |||
| 149 | // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all. | 149 | // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all. |
| 150 | .route("/repo-settings/{id}/general", post(repo::settings_general)) | 150 | .route("/repo-settings/{id}/general", post(repo::settings_general)) |
| 151 | .route("/repo-settings/{id}/delete", post(repo::settings_delete)) | 151 | .route("/repo-settings/{id}/delete", post(repo::settings_delete)) |
| 152 | .route( | ||
| 153 | "/repo-settings/{id}/collaborators", | ||
| 154 | post(repo::settings_collaborator_add), | ||
| 155 | ) | ||
| 156 | .route( | ||
| 157 | "/repo-settings/{id}/collaborators/remove", | ||
| 158 | post(repo::settings_collaborator_remove), | ||
| 159 | ) | ||
| 152 | .route("/healthz", get(serve_assets::healthz)) | 160 | .route("/healthz", get(serve_assets::healthz)) |
| 153 | .route("/version", get(serve_assets::version)) | 161 | .route("/version", get(serve_assets::version)) |
| 154 | .route("/theme.css", get(serve_assets::theme_default)) | 162 | .route("/theme.css", get(serve_assets::theme_default)) |
crates/web/src/pages.rs +7 −5
| @@ -608,11 +608,13 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup { | |||
| 608 | h2 { "Avatar" } | 608 | h2 { "Avatar" } |
| 609 | div class="card avatar-settings" { | 609 | div class="card avatar-settings" { |
| 610 | img class="avatar avatar-lg" src={ "/avatar/" (user.username) } alt="Your avatar"; | 610 | img class="avatar avatar-lg" src={ "/avatar/" (user.username) } alt="Your avatar"; |
| 611 | form method="post" action="/settings/avatar" enctype="multipart/form-data" { | 611 | form class="avatar-form" method="post" action="/settings/avatar" enctype="multipart/form-data" { |
| 612 | input type="hidden" name="_csrf" value=(csrf); | 612 | input type="hidden" name="_csrf" value=(csrf); |
| 613 | p class="muted" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." } | 613 | p class="muted field-hint" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." } |
| 614 | input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp"; | 614 | div class="avatar-upload-row" { |
| 615 | button class="btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } } | 615 | input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp"; |
| 616 | button class="btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } } | ||
| 617 | } | ||
| 616 | } | 618 | } |
| 617 | } | 619 | } |
| 618 | } | 620 | } |
| @@ -639,7 +641,7 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup { | |||
| 639 | class="link-slot" value=(user.links.get(i).map_or("", String::as_str)) | 641 | class="link-slot" value=(user.links.get(i).map_or("", String::as_str)) |
| 640 | placeholder="https://example.com"; | 642 | placeholder="https://example.com"; |
| 641 | } | 643 | } |
| 642 | button class="btn link-add" type="button" data-link-add { "Add link" } | 644 | button class="btn link-add" type="button" data-link-add { "Add" } |
| 643 | } | 645 | } |
| 644 | button class="btn btn-primary" type="submit" { "Save profile" } | 646 | button class="btn btn-primary" type="submit" { "Save profile" } |
| 645 | } | 647 | } |
crates/web/src/repo.rs +143 −0
| @@ -724,6 +724,7 @@ async fn repo_settings_view( | |||
| 724 | return Err(AppError::NotFound); | 724 | return Err(AppError::NotFound); |
| 725 | } | 725 | } |
| 726 | let branches = branch_names(state, &ctx.repo.id).await; | 726 | let branches = branch_names(state, &ctx.repo.id).await; |
| 727 | let collaborators = state.store.list_collaborators(&ctx.repo.id).await?; | ||
| 727 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | 728 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); |
| 728 | 729 | ||
| 729 | let id = &ctx.repo.id; | 730 | let id = &ctx.repo.id; |
| @@ -733,6 +734,9 @@ async fn repo_settings_view( | |||
| 733 | option value=(value.as_str()) selected[vis == value] { (label) } | 734 | option value=(value.as_str()) selected[vis == value] { (label) } |
| 734 | } | 735 | } |
| 735 | }; | 736 | }; |
| 737 | let perm_option = |value: &str, label: &str| { | ||
| 738 | html! { option value=(value) { (label) } } | ||
| 739 | }; | ||
| 736 | let body = html! { | 740 | let body = html! { |
| 737 | (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches)) | 741 | (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches)) |
| 738 | section class="listing" { | 742 | section class="listing" { |
| @@ -753,6 +757,44 @@ async fn repo_settings_view( | |||
| 753 | } | 757 | } |
| 754 | } | 758 | } |
| 755 | section class="listing" { | 759 | section class="listing" { |
| 760 | h2 { "Collaborators" } | ||
| 761 | div class="card" { | ||
| 762 | @if collaborators.is_empty() { | ||
| 763 | p class="muted" { "No collaborators yet." } | ||
| 764 | } @else { | ||
| 765 | ul class="entry-list collab-list" { | ||
| 766 | @for c in &collaborators { | ||
| 767 | li class="entry-row" { | ||
| 768 | div class="entry-row-body" { | ||
| 769 | a class="entry-row-title" href={ "/" (c.username) } { (c.username) } | ||
| 770 | div class="entry-row-meta muted" { (c.permission) } | ||
| 771 | } | ||
| 772 | div class="entry-row-actions" { | ||
| 773 | form method="post" | ||
| 774 | action=(format!("/repo-settings/{id}/collaborators/remove")) { | ||
| 775 | input type="hidden" name="_csrf" value=(chrome.csrf); | ||
| 776 | input type="hidden" name="user_id" value=(c.user_id); | ||
| 777 | button class="btn btn-danger" type="submit" { "Remove" } | ||
| 778 | } | ||
| 779 | } | ||
| 780 | } | ||
| 781 | } | ||
| 782 | } | ||
| 783 | } | ||
| 784 | form class="collab-add" method="post" | ||
| 785 | action=(format!("/repo-settings/{id}/collaborators")) { | ||
| 786 | input type="hidden" name="_csrf" value=(chrome.csrf); | ||
| 787 | input type="text" name="username" placeholder="Username" aria-label="Username" required; | ||
| 788 | select name="permission" aria-label="Permission" { | ||
| 789 | (perm_option("read", "Read")) | ||
| 790 | (perm_option("write", "Write")) | ||
| 791 | (perm_option("admin", "Admin")) | ||
| 792 | } | ||
| 793 | button class="btn btn-primary" type="submit" { "Add" } | ||
| 794 | } | ||
| 795 | } | ||
| 796 | } | ||
| 797 | section class="listing" { | ||
| 756 | h2 { "Danger zone" } | 798 | h2 { "Danger zone" } |
| 757 | div class="card danger-zone" { | 799 | div class="card danger-zone" { |
| 758 | p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." } | 800 | p { strong { "Delete this repository." } " This permanently removes it from fabrica; the on-disk copy is moved to trash." } |
| @@ -914,6 +956,107 @@ pub async fn settings_delete( | |||
| 914 | } | 956 | } |
| 915 | } | 957 | } |
| 916 | 958 | ||
| 959 | /// The `/-/settings` URL for a repo, resolving the owner username. | ||
| 960 | async fn repo_settings_url(state: &AppState, repo: &Repo) -> String { | ||
| 961 | let owner = state | ||
| 962 | .store | ||
| 963 | .user_by_id(&repo.owner_id) | ||
| 964 | .await | ||
| 965 | .ok() | ||
| 966 | .flatten() | ||
| 967 | .map_or_else(|| repo.owner_id.clone(), |u| u.username); | ||
| 968 | format!("/{owner}/{}/-/settings", repo.path) | ||
| 969 | } | ||
| 970 | |||
| 971 | /// The add-collaborator form. | ||
| 972 | #[derive(Debug, Deserialize)] | ||
| 973 | pub struct CollaboratorAddForm { | ||
| 974 | /// The collaborator's username. | ||
| 975 | #[serde(default)] | ||
| 976 | username: String, | ||
| 977 | /// The permission to grant (`read` | `write` | `admin`). | ||
| 978 | #[serde(default)] | ||
| 979 | permission: String, | ||
| 980 | /// CSRF token. | ||
| 981 | #[serde(rename = "_csrf")] | ||
| 982 | csrf: String, | ||
| 983 | } | ||
| 984 | |||
| 985 | /// `POST /repo-settings/{id}/collaborators` — add or update a collaborator. | ||
| 986 | pub async fn settings_collaborator_add( | ||
| 987 | State(state): State<AppState>, | ||
| 988 | MaybeUser(viewer): MaybeUser, | ||
| 989 | jar: CookieJar, | ||
| 990 | headers: HeaderMap, | ||
| 991 | Path(id): Path<String>, | ||
| 992 | Form(form): Form<CollaboratorAddForm>, | ||
| 993 | ) -> Response { | ||
| 994 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 995 | return AppError::Forbidden.into_response(); | ||
| 996 | } | ||
| 997 | let result = async { | ||
| 998 | let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; | ||
| 999 | let permission = auth::Permission::parse(&form.permission) | ||
| 1000 | .ok_or_else(|| AppError::BadRequest("Invalid permission.".to_string()))?; | ||
| 1001 | let target = state | ||
| 1002 | .store | ||
| 1003 | .user_by_username(form.username.trim()) | ||
| 1004 | .await? | ||
| 1005 | .ok_or_else(|| AppError::BadRequest("No such user.".to_string()))?; | ||
| 1006 | // The owner is already an admin; adding them as a collaborator is a no-op. | ||
| 1007 | if target.id != repo.owner_id { | ||
| 1008 | state | ||
| 1009 | .store | ||
| 1010 | .set_collaborator(&repo.id, &target.id, permission.as_str()) | ||
| 1011 | .await?; | ||
| 1012 | } | ||
| 1013 | Ok::<String, AppError>(repo_settings_url(&state, &repo).await) | ||
| 1014 | } | ||
| 1015 | .await; | ||
| 1016 | match result { | ||
| 1017 | Ok(dest) => Redirect::to(&dest).into_response(), | ||
| 1018 | Err(err) => err.into_response(), | ||
| 1019 | } | ||
| 1020 | } | ||
| 1021 | |||
| 1022 | /// The remove-collaborator form. | ||
| 1023 | #[derive(Debug, Deserialize)] | ||
| 1024 | pub struct CollaboratorRemoveForm { | ||
| 1025 | /// The collaborator's user id. | ||
| 1026 | #[serde(default)] | ||
| 1027 | user_id: String, | ||
| 1028 | /// CSRF token. | ||
| 1029 | #[serde(rename = "_csrf")] | ||
| 1030 | csrf: String, | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | /// `POST /repo-settings/{id}/collaborators/remove` — remove a collaborator. | ||
| 1034 | pub async fn settings_collaborator_remove( | ||
| 1035 | State(state): State<AppState>, | ||
| 1036 | MaybeUser(viewer): MaybeUser, | ||
| 1037 | jar: CookieJar, | ||
| 1038 | headers: HeaderMap, | ||
| 1039 | Path(id): Path<String>, | ||
| 1040 | Form(form): Form<CollaboratorRemoveForm>, | ||
| 1041 | ) -> Response { | ||
| 1042 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 1043 | return AppError::Forbidden.into_response(); | ||
| 1044 | } | ||
| 1045 | let result = async { | ||
| 1046 | let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?; | ||
| 1047 | state | ||
| 1048 | .store | ||
| 1049 | .remove_collaborator(&repo.id, &form.user_id) | ||
| 1050 | .await?; | ||
| 1051 | Ok::<String, AppError>(repo_settings_url(&state, &repo).await) | ||
| 1052 | } | ||
| 1053 | .await; | ||
| 1054 | match result { | ||
| 1055 | Ok(dest) => Redirect::to(&dest).into_response(), | ||
| 1056 | Err(err) => err.into_response(), | ||
| 1057 | } | ||
| 1058 | } | ||
| 1059 | |||
| 917 | // ---- Commit list ---- | 1060 | // ---- Commit list ---- |
| 918 | 1061 | ||
| 919 | async fn commits_view( | 1062 | async fn commits_view( |