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 {
279279 margin-top: 0;
280280 }
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+
282311 label {
283312 display: block;
284313 margin: 0.75rem 0 0.25rem;
@@ -314,13 +343,23 @@ select:focus {
314343 margin: 0.25rem 0 0.5rem;
315344 font-size: 0.85em;
316345 }
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+}
317353 .link-slot {
318- margin-bottom: 0.5rem;
354+ flex: 1 1 100%;
355+}
356+.link-slot.last-visible {
357+ flex: 1 1 auto;
319358 }
320359 .link-add {
321- margin-top: 0.25rem;
360+ flex: 0 0 auto;
322361 }
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. */
324363 [hidden] {
325364 display: none !important;
326365 }
@@ -708,10 +747,30 @@ select:focus {
708747 gap: 1.25rem;
709748 flex-wrap: wrap;
710749 }
711-.avatar-settings form {
750+.avatar-form {
712751 flex: 1;
713752 min-width: 15rem;
714753 }
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
716775 /* ---- Search hits ---- */
717776 .search-hit {
assets/fabrica.js +13 −5
@@ -62,7 +62,8 @@
6262 document.querySelectorAll("[data-link-fields]").forEach(function (wrap) {
6363 var slots = Array.prototype.slice.call(wrap.querySelectorAll(".link-slot"));
6464 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;
6667 slots.forEach(function (s) {
6768 if (s.value.trim()) {
6869 s.hidden = false;
@@ -73,20 +74,27 @@
7374 s.hidden = true;
7475 }
7576 });
76- function anyHidden() {
77- return slots.some(function (s) { return s.hidden; });
77+ // Keep the Add button beside the last visible slot; hide it once all show.
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; });
7886 }
7987 if (addBtn) {
80- addBtn.hidden = !anyHidden();
8188 addBtn.addEventListener("click", function () {
8289 var next = slots.filter(function (s) { return s.hidden; })[0];
8390 if (next) {
8491 next.hidden = false;
8592 next.focus();
8693 }
87- addBtn.hidden = !anyHidden();
94+ mark();
8895 });
8996 }
97+ mark();
9098 });
9199
92100 // ---- Clipboard buttons ----
crates/cli/src/repo_cmd.rs +137 −0
@@ -85,6 +85,12 @@ pub(crate) enum RepoCommand {
8585 #[arg(long)]
8686 public: bool,
8787 },
88+ /// Manage a repository's collaborators.
89+ Collaborator {
90+ /// The collaborator action.
91+ #[command(subcommand)]
92+ command: CollaboratorCommand,
93+ },
8894 /// Print the resolved on-disk directory of a repository.
8995 Path {
9096 /// The owning user.
@@ -94,6 +100,39 @@ pub(crate) enum RepoCommand {
94100 },
95101 }
96102
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+
97136 /// Dispatch a `repo` subcommand.
98137 pub(crate) fn run(
99138 command: &RepoCommand,
@@ -141,6 +180,28 @@ pub(crate) fn run(
141180 name,
142181 (*private, *internal, *public),
143182 )),
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+ },
144205 RepoCommand::Path { user, name } => block_on(path(config_path, user, name)),
145206 }
146207 }
@@ -379,6 +440,82 @@ async fn visibility(
379440 Ok(ExitCode::SUCCESS)
380441 }
381442
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+
382519 async fn path(config_path: Option<&Path>, user: &str, name: &str) -> anyhow::Result<ExitCode> {
383520 let (config, store) = open_store(config_path).await?;
384521 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
4444 pub use crate::invites::NewInvite;
4545 pub use crate::keys::NewKey;
46-pub use crate::repos::NewRepo;
46+pub use crate::repos::{Collaborator, NewRepo};
4747 pub use crate::sessions::NewSession;
4848 pub use crate::tokens::NewToken;
4949 pub use crate::users::{NewUser, ProfileUpdate};
crates/store/src/repos.rs +79 −0
@@ -34,6 +34,17 @@ pub struct NewRepo {
3434 pub default_branch: String,
3535 }
3636
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+
3748 impl Store {
3849 /// Create a repository, returning the persisted row.
3950 ///
@@ -261,6 +272,74 @@ impl Store {
261272 Ok(row.as_ref().map(|r| r.try_get("permission")).transpose()?)
262273 }
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+
264343 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
265344 // A method (not an associated fn) for symmetry with the other row mappers,
266345 // 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() {
146146 }
147147
148148 #[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]
149224 async fn duplicate_username_conflicts_case_insensitively() {
150225 for fx in sqlite_fixtures().await {
151226 fx.store
crates/web/src/lib.rs +8 −0
@@ -149,6 +149,14 @@ pub fn build_router(state: AppState) -> Router {
149149 // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all.
150150 .route("/repo-settings/{id}/general", post(repo::settings_general))
151151 .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+ )
152160 .route("/healthz", get(serve_assets::healthz))
153161 .route("/version", get(serve_assets::version))
154162 .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 {
608608 h2 { "Avatar" }
609609 div class="card avatar-settings" {
610610 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" {
612612 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." }
614- input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp";
615- button class="btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } }
613+ p class="muted field-hint" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." }
614+ div class="avatar-upload-row" {
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+ }
616618 }
617619 }
618620 }
@@ -639,7 +641,7 @@ fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
639641 class="link-slot" value=(user.links.get(i).map_or("", String::as_str))
640642 placeholder="https://example.com";
641643 }
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" }
643645 }
644646 button class="btn btn-primary" type="submit" { "Save profile" }
645647 }
crates/web/src/repo.rs +143 −0
@@ -724,6 +724,7 @@ async fn repo_settings_view(
724724 return Err(AppError::NotFound);
725725 }
726726 let branches = branch_names(state, &ctx.repo.id).await;
727+ let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;
727728 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
728729
729730 let id = &ctx.repo.id;
@@ -733,6 +734,9 @@ async fn repo_settings_view(
733734 option value=(value.as_str()) selected[vis == value] { (label) }
734735 }
735736 };
737+ let perm_option = |value: &str, label: &str| {
738+ html! { option value=(value) { (label) } }
739+ };
736740 let body = html! {
737741 (repo_header(state, &ctx, Tab::Settings, &ctx.repo.default_branch, &branches))
738742 section class="listing" {
@@ -753,6 +757,44 @@ async fn repo_settings_view(
753757 }
754758 }
755759 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" {
756798 h2 { "Danger zone" }
757799 div class="card danger-zone" {
758800 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(
914956 }
915957 }
916958
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+
9171060 // ---- Commit list ----
9181061
9191062 async fn commits_view(