fabrica

hanna/fabrica

feat: repository collaborators, plus settings/profile UI polish

0a5cbfd · hanna committed on 2026-07-25

Add collaborator management: store add/list/remove (portable upsert), a
Collaborators section on the repo Settings page (add by username with a
read/write/admin permission, remove), and a `repo collaborator add/rm/list`
CLI. Also tidy the avatar upload row (file input + Upload aligned) and move
the profile "Add" link button beside the last empty slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
9 files changed · +526 −15UnifiedSplit
assets/base.css +63 −4
@@ -279,6 +279,35 @@ body.drawer-open .drawer-backdrop {
279 margin-top: 0;279 margin-top: 0;
280}280}
281281
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
282label {311label {
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}
715774
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 });
9199
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}
96102
103/// Actions under `fabrica repo collaborator`.
104#[derive(Debug, Subcommand)]
105pub(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.
98pub(crate) fn run(137pub(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}
381442
443async 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
470async 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
491async 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
382async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {519async 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;
4343
44pub use crate::invites::NewInvite;44pub use crate::invites::NewInvite;
45pub use crate::keys::NewKey;45pub use crate::keys::NewKey;
46pub use crate::repos::NewRepo;46pub use crate::repos::{Collaborator, NewRepo};
47pub use crate::sessions::NewSession;47pub use crate::sessions::NewSession;
48pub use crate::tokens::NewToken;48pub use crate::tokens::NewToken;
49pub use crate::users::{NewUser, ProfileUpdate};49pub 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}
3636
37/// A repo collaborator with their username resolved, for listings.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub 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
37impl Store {48impl 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 }
263274
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}
147147
148#[tokio::test]148#[tokio::test]
149async 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]
149async fn duplicate_username_conflicts_case_insensitively() {224async fn duplicate_username_conflicts_case_insensitively() {
150 for fx in sqlite_fixtures().await {225 for fx in sqlite_fixtures().await {
151 fx.store226 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());
728729
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}
916958
959/// The `/-/settings` URL for a repo, resolving the owner username.
960async 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)]
973pub 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.
986pub 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)]
1024pub 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.
1034pub 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 ----
9181061
919async fn commits_view(1062async fn commits_view(