//! Page: collaborators (`/{owner}/{repo}/settings/collaborators`). Anyone who may manage the repo //! (the owner, an instance admin, or a collaborator with the Admin role) sees it; everyone else gets //! 403, and hidden repos stay 404 through `load_repo`. //! //! Rules: //! - The owner cannot be added as a collaborator. //! - Adding an existing collaborator updates their role. //! - Only the owner and instance admins may grant the Admin role, change an existing Admin's role, //! or remove an Admin. Other Admin collaborators get 403 for those actions. //! - Collaborators may remove themselves. A self-removal redirects to the repo home page instead, //! because the settings page is no longer visible to them and a notice there would be confusing. use forge_core::store::orgs::OrgMemberRole; use forge_core::{AuditAction, Role, User}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::cookie::{Cookie, Cookies, SameSite, cookies}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::repo_admin::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::state::AppState; use crate::views::{alert, avatar_url, display_name, shell}; path_param!(owner); path_param!(repo); /// Cookie that carries a one-shot notice to the next collaborators page. Its value is one of the /// fixed words in `NOTICES`, never user text. const NOTICE_COOKIE: &str = "forge_collab_notice"; /// Notice words, the text each one shows, and whether it is an error. const NOTICES: [(&str, K, bool); 8] = [ ("added", K::CollabAdded, false), ("updated", K::RoleUpdated, false), ("removed", K::CollabRemoved, false), ("empty_username", K::EnterUsername, true), ("unknown_user", K::UnknownUser, true), ("owner", K::OwnerIsMember, true), ("invalid_role", K::InvalidRole, true), ("not_collaborator", K::NotCollaborator, true), ]; /// Roles in the order the selects and the legend show them. const ROLES: [Role; 5] = [ Role::Read, Role::Triage, Role::Write, Role::Maintain, Role::Admin, ]; /// What each role may do, for the legend. const LEGEND: [(Role, K); 5] = [ (Role::Read, K::RoleReadDesc), (Role::Triage, K::RoleTriageDesc), (Role::Write, K::RoleWriteDesc), (Role::Maintain, K::RoleMaintainDesc), (Role::Admin, K::RoleAdminDesc), ]; #[derive(Deserialize)] struct AddForm { #[serde(default)] csrf: String, #[serde(default)] username: String, #[serde(default)] role: String, } #[derive(Deserialize)] struct RoleForm { #[serde(default)] csrf: String, #[serde(default)] user_id: String, #[serde(default)] role: String, } #[derive(Deserialize)] struct RemoveForm { #[serde(default)] csrf: String, #[serde(default)] user_id: String, } /// One table row: a collaborator and their current role. struct CollabRow { user_id: String, username: String, /// What to show: the login name, or "Deleted user" for the ghost account. label: String, avatar: String, role: Role, } /// Sets the notice shown on the next collaborators page view. fn set_notice(cx: &Cx, word: &'static str) { cookies(cx).add( Cookie::build((NOTICE_COOKIE, word)) .path("/") .http_only(true) .same_site(SameSite::Lax) .build(), ); } /// Reads and clears the notice cookie. Only the fixed words are shown. fn take_notice(cx: &Cx) -> Option<(K, bool)> { let jar = cookies(cx); let value = jar.get(NOTICE_COOKIE)?.value().to_owned(); jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/")); NOTICES .iter() .find(|(word, _, _)| *word == value) .map(|(_, key, is_error)| (*key, *is_error)) } /// Whether the actor may grant or change the Admin role: an instance admin, the owner of a /// person-owned repo, or an owner of the org that owns the repo. fn privileged(ctx: &RepoCtx, actor: &User, org_owner: bool) -> bool { actor.is_admin || actor.id == ctx.repo.owner_id || org_owner } /// Whether `actor` owns the organization that owns the repo. async fn owns_repo_org(state: &AppState, ctx: &RepoCtx, actor: &User) -> Result { Ok(matches!( state .store .org_member_role(&ctx.repo.owner_id, &actor.id) .await?, Some(OrgMemberRole::Owner) )) } /// `Owner/name` for redirects and form actions. fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn collaborators_redirect(title: &str) -> SeeOther { see_other(format!("/{title}/settings/collaborators")) } /// The role legend: what each role may do. #[component] async fn role_legend(lang: Lang) -> Result { Ok(view! {

(g::t(lang, K::Roles))

for entry in LEGEND.iter() {
(entry.0.as_str())
(g::t(lang, entry.1))
}
}) } /// The collaborators table. Each row has a role form (Save) and a remove form (Remove). #[component] async fn collaborators_table( lang: Lang, rows: Vec, role_action: String, remove_action: String, csrf: String, ) -> Result { Ok(view! { if rows.is_empty() {

(g::t(lang, K::NoCollaborators))

} else {
for row in rows.iter() { }
(g::t(lang, K::ColUser)) (g::t(lang, K::Role)) (g::t(lang, K::Remove))
if row.label == row.username { (row.label.as_str()) } else { (row.label.as_str()) }
} }) } /// The add form: a username and a role. Re-adding an existing collaborator updates the role. #[component] async fn add_form(lang: Lang, add_action: String, csrf: String) -> Result { Ok(view! {

(g::t(lang, K::AddCollaboratorHeading))

(g::t(lang, K::AddCollaboratorHelp))

}) } /// Everything the collaborators page shows besides the repo header. struct FrameData { page_title: String, title: String, head: RepoHead, } impl FrameData { async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result { let title = title_of(ctx); Ok(Self { page_title: g::collaborators_page_title(lang, &title), title, head: ctx.head(state, "settings").await?, }) } } /// The page body: repo header with Settings active, the notice, the table, the legend and the add form. #[component] async fn collaborators_body( lang: Lang, frame: FrameData, rows: Vec, csrf: String, notice: Option<(K, bool)>, ) -> Result { let role_action = format!("/{}/settings/collaborators/role", frame.title); let remove_action = format!("/{}/settings/collaborators/remove", frame.title); let add_action = format!("/{}/settings/collaborators/add", frame.title); let header = frame.head; let notice = notice.map(|(key, is_error)| (g::t(lang, key), is_error)); Ok(view! { repo_head(head: header) if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} }

(g::t(lang, K::Collaborators))

(g::t(lang, K::CollaboratorsHelp))

collaborators_table(lang: lang, rows: rows, role_action: role_action, remove_action: remove_action, csrf: csrf.clone()) add_form(lang: lang, add_action: add_action, csrf: csrf)
role_legend(lang: lang) }) } /// Gets the collaborators page. Hidden repos are 404 (`load_repo`), anonymous viewers go to login, /// and signed-in viewers who may not manage the repo get 403. #[page("/{owner}/{repo}/settings/collaborators")] async fn collaborators_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let collaborators = state.store.list_collaborators(&ctx.repo.id).await?; let rows = collaborators .into_iter() .map(|(user, role)| CollabRow { user_id: user.id.clone(), avatar: avatar_url(&user.username, user.avatar_hash.as_deref()), label: display_name(lang, &user.username), username: user.username, role, }) .collect(); let notice = take_notice(cx); let frame = FrameData::new(&ctx, state, lang).await?; let page_title = frame.page_title.clone(); let csrf = session.csrf_token.clone(); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), collaborators_body(lang: lang, frame: frame, rows: rows, csrf: csrf, notice: notice)) }) } /// Adds a collaborator or updates an existing one's role. Admin grants need the owner or an instance admin. #[route(POST "/{owner}/{repo}/settings/collaborators/add")] async fn collaborators_add(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (actor, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let title = title_of(&ctx); let state = app_context::(cx); let Ok(role) = form.role.trim().parse::() else { set_notice(cx, "invalid_role"); return Ok(collaborators_redirect(&title)); }; let username = form.username.trim(); if username.is_empty() { set_notice(cx, "empty_username"); return Ok(collaborators_redirect(&title)); } let Some(target) = state.store.user_by_username(username).await? else { set_notice(cx, "unknown_user"); return Ok(collaborators_redirect(&title)); }; // An organization is not a collaborator: it never acts on a repo. if target.is_org() { set_notice(cx, "unknown_user"); return Ok(collaborators_redirect(&title)); } if target.id == ctx.repo.owner_id { set_notice(cx, "owner"); return Ok(collaborators_redirect(&title)); } let existing = state .store .collaborator_role(&ctx.repo.id, &target.id) .await?; if (role == Role::Admin || existing == Some(Role::Admin)) && !privileged(&ctx, &actor, owns_repo_org(state, &ctx, &actor).await?) { return Err(forbidden().into()); } state .store .set_collaborator(&ctx.repo.id, &target.id, role) .await?; audit::record( &state.store, Some(cx), Actor::User(&actor), if existing.is_some() { AuditAction::CollaboratorRoleChanged } else { AuditAction::CollaboratorAdded }, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "user": target.username, "role": role.as_str() }), ) .await; tracing::info!( actor_id = %actor.id, repo_id = %ctx.repo.id, repo = %title, target_id = %target.id, role = role.as_str(), updated = existing.is_some(), "collaborator set" ); set_notice( cx, if existing.is_some() { "updated" } else { "added" }, ); Ok(collaborators_redirect(&title)) } /// Changes an existing collaborator's role. Only existing collaborators can be changed here. #[route(POST "/{owner}/{repo}/settings/collaborators/role")] async fn collaborators_role(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (actor, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let title = title_of(&ctx); let state = app_context::(cx); let Ok(role) = form.role.trim().parse::() else { set_notice(cx, "invalid_role"); return Ok(collaborators_redirect(&title)); }; let Some(existing) = state .store .collaborator_role(&ctx.repo.id, form.user_id.trim()) .await? else { set_notice(cx, "not_collaborator"); return Ok(collaborators_redirect(&title)); }; if (role == Role::Admin || existing == Role::Admin) && !privileged(&ctx, &actor, owns_repo_org(state, &ctx, &actor).await?) { return Err(forbidden().into()); } state .store .set_collaborator(&ctx.repo.id, form.user_id.trim(), role) .await?; audit::record( &state.store, Some(cx), Actor::User(&actor), AuditAction::CollaboratorRoleChanged, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "user": form.user_id.trim(), "role": role.as_str() }), ) .await; tracing::info!( actor_id = %actor.id, repo_id = %ctx.repo.id, repo = %title, target_id = %form.user_id, role = role.as_str(), "collaborator role changed" ); set_notice(cx, "updated"); Ok(collaborators_redirect(&title)) } /// Removes a collaborator. Removing another Admin needs the owner or an instance admin. Removing /// yourself is allowed and redirects to the repo home page. #[route(POST "/{owner}/{repo}/settings/collaborators/remove")] async fn collaborators_remove(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (actor, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let title = title_of(&ctx); let state = app_context::(cx); let target_id = form.user_id.trim(); let self_removal = target_id == actor.id; if !self_removal { let existing = state .store .collaborator_role(&ctx.repo.id, target_id) .await?; if existing.is_none() { set_notice(cx, "not_collaborator"); return Ok(collaborators_redirect(&title)); } if existing == Some(Role::Admin) && !privileged(&ctx, &actor, owns_repo_org(state, &ctx, &actor).await?) { return Err(forbidden().into()); } } let removed = state .store .remove_collaborator(&ctx.repo.id, target_id) .await?; if removed { audit::record( &state.store, Some(cx), Actor::User(&actor), AuditAction::CollaboratorRemoved, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "user": target_id }), ) .await; } tracing::info!( actor_id = %actor.id, repo_id = %ctx.repo.id, repo = %title, target_id = %target_id, removed, "collaborator removed" ); if self_removal { return Ok(see_other(format!("/{title}"))); } set_notice( cx, if removed { "removed" } else { "not_collaborator" }, ); Ok(collaborators_redirect(&title)) }