//! Pages: organization teams (`/org/{org}/teams`, `/org/{org}/teams/{team}`). Members see the teams, //! their members and the repositories they cover. Owners create, edit and delete teams, add people //! to them (an organization member only), and link the org's own repositories. //! //! A team's role is what its members get on the repos it covers: every repo the org owns when //! `all_repos` is set, otherwise only the repos linked to it (see `Store::org_repo_role`). use forge_core::authz::{Action, authorize}; use forge_core::{AuditAction, Role}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, not_found, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::i18n::orgs::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::org_ctx::{ OrgCtx, TEAM_ROLES, load_org, org_head, org_notice, require_form_csrf, require_member, require_owner, set_notice, take_notice, }; use crate::state::AppState; use crate::views::{avatar_url, display_name, repo_href, shell}; path_param!(org); path_param!(team); const MAX_TEAM_DESCRIPTION_CHARS: usize = 350; #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct TeamForm { #[serde(default)] csrf: String, #[serde(default)] name: String, #[serde(default)] description: String, #[serde(default)] role: String, /// The checkbox sends `on` when checked and nothing when unchecked. #[serde(default)] all_repos: String, } #[derive(Deserialize)] struct TeamMemberForm { #[serde(default)] csrf: String, #[serde(default)] username: String, } #[derive(Deserialize)] struct TeamUserForm { #[serde(default)] csrf: String, #[serde(default)] user_id: String, } #[derive(Deserialize)] struct TeamRepoForm { #[serde(default)] csrf: String, #[serde(default)] repo_id: String, } /// One row of the teams list. struct TeamRow { href: String, name: String, description: String, role: &'static str, all_repos: bool, } /// A team member shown on the team page. struct TeamMemberRow { user_id: String, username: String, label: String, avatar: String, } /// A repo linked to the team, shown on the team page. struct TeamRepoRow { repo_id: String, href: String, title: String, } /// A repo the org owns that is not linked to the team yet: the select's options. struct RepoChoice { id: String, title: String, } /// Everything the team page shows besides the notice. struct TeamPage { org_name: String, org_description: String, base: String, owner: bool, csrf: String, team_name: String, team_description: String, team_role: &'static str, team_all_repos: bool, members: Vec, repos: Vec, choices: Vec, } /// The role a form sent, if it is one a team may carry. fn parse_team_role(word: &str) -> Option { TEAM_ROLES .iter() .copied() .find(|role| role.as_str() == word.trim()) } /// The notice word for a store error from creating or renaming a team, or `None` for an error /// that is not the user's to fix. fn team_error_notice(err: &forge_core::Error) -> Option<&'static str> { match err { forge_core::Error::Conflict(_) => Some("team_exists"), forge_core::Error::InvalidName(_) | forge_core::Error::Invalid(_) => Some("invalid_name"), _ => None, } } /// The team named in the path, if it belongs to this org. Anything else is 404. async fn team_of( state: &AppState, ctx: &OrgCtx, team_id: &str, ) -> Result { match state.store.team_by_id(team_id).await? { Some(team) if team.org_id == ctx.org.id => Ok(team), _ => Err(not_found().into()), } } #[component] async fn teams_body( name: String, description: String, base: String, owner: bool, csrf: String, rows: Vec, notice: Option<(&'static str, bool)>, lang: Lang, ) -> Result { let create_action = format!("{base}/teams/new"); Ok(view! { org_head(name: name, description: description, active: "teams", is_owner: owner, lang: lang) org_notice(notice: notice)

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

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

if rows.is_empty() {

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

} else {
    for row in rows.iter() {
  • (row.name.as_str()) (g::coverage_text(lang, row.role, row.all_repos)) if !row.description.is_empty() { (row.description.as_str()) }
  • }
}
if owner {

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

} }) } /// Gets the teams list. Members only. #[page("/org/{org}/teams")] async fn org_teams_page(cx: &Cx) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_member(&ctx)?; let lang = i18n::lang(cx, Some(&ctx.viewer.0)); let state = app_context::(cx); let teams = state.store.teams_for_org(&ctx.org.id).await?; let rows: Vec = teams .into_iter() .map(|team| TeamRow { href: ctx.href(&format!("/teams/{}", team.id)), role: team.role.as_str(), all_repos: team.all_repos, name: team.name, description: team.description, }) .collect(); let owner = ctx.is_owner(); let notice = take_notice(cx, lang); let title = g::page_title(lang, &ctx.org.username, K::Teams); let name = ctx.org.username.clone(); let description = ctx.org.about.clone(); let base = ctx.href(""); let csrf = ctx.viewer.1.csrf_token.clone(); Ok(view! { cx => shell(title: title.as_str(), viewer: Some(&ctx.viewer), teams_body(name: name, description: description, base: base, owner: owner, csrf: csrf, rows: rows, notice: notice, lang: lang)) }) } #[component] async fn team_body( data: TeamPage, notice: Option<(&'static str, bool)>, lang: Lang, ) -> Result { let base = data.base.clone(); let update_action = format!("{base}/update"); let delete_action = format!("{base}/delete"); let add_member_action = format!("{base}/members/add"); let remove_member_action = format!("{base}/members/remove"); let add_repo_action = format!("{base}/repos/add"); let remove_repo_action = format!("{base}/repos/remove"); let csrf = data.csrf.clone(); let owner = data.owner; let has_choices = !data.choices.is_empty(); let has_description = !data.team_description.is_empty(); Ok(view! { org_head(name: data.org_name.clone(), description: data.org_description.clone(), active: "teams", is_owner: owner, lang: lang) org_notice(notice: notice)

(data.team_name.as_str())

if has_description {

(data.team_description.as_str())

}

(g::coverage_text(lang, data.team_role, data.team_all_repos))

if owner {
}

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

if data.members.is_empty() {

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

} else { } if owner {
}

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

if data.repos.is_empty() {

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

} else { } if owner && has_choices {
}
}) } /// Gets one team's page. Members only; a team of another org is 404. #[page("/org/{org}/teams/{team}")] async fn org_team_page(cx: &Cx) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_member(&ctx)?; let lang = i18n::lang(cx, Some(&ctx.viewer.0)); let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let members: Vec = state .store .team_members(&team.id) .await? .into_iter() .map(|user| TeamMemberRow { user_id: user.id.clone(), avatar: avatar_url(&user.username, user.avatar_hash.as_deref()), label: display_name(lang, &user.username), username: user.username, }) .collect(); // Only the linked repos the viewer may read are named, so a private repo's name stays hidden. let linked = state.store.team_repos(&team.id).await?; let mut repos: Vec = Vec::new(); for repo in linked.iter() { let role = state .store .collaborator_role(&repo.id, &ctx.viewer.0.id) .await?; let org_role = state .store .org_repo_role(&repo.owner_id, &ctx.viewer.0.id, &repo.id) .await?; if authorize(Some(&ctx.viewer.0), repo, role, org_role, Action::Read).is_ok() { repos.push(TeamRepoRow { repo_id: repo.id.clone(), href: repo_href(&repo.owner_name, &repo.name, "", ""), title: format!("{}/{}", repo.owner_name, repo.name), }); } } let choices: Vec = state .store .list_repos_by_owner(&ctx.org.id) .await? .into_iter() .filter(|repo| !linked.iter().any(|l| l.id == repo.id)) .map(|repo| RepoChoice { title: format!("{}/{}", repo.owner_name, repo.name), id: repo.id, }) .collect(); let title = format!("{} \u{b7} {}", ctx.org.username, team.name); let data = TeamPage { org_name: ctx.org.username.clone(), org_description: ctx.org.about.clone(), base: ctx.href(&format!("/teams/{}", team.id)), owner: ctx.is_owner(), csrf: ctx.viewer.1.csrf_token.clone(), team_name: team.name.clone(), team_description: team.description.clone(), team_role: team.role.as_str(), team_all_repos: team.all_repos, members, repos, choices, }; let notice = take_notice(cx, lang); Ok(view! { cx => shell(title: title.as_str(), viewer: Some(&ctx.viewer), team_body(data: data, notice: notice, lang: lang)) }) } /// Creates a team. Owners only. #[route(POST "/org/{org}/teams/new")] async fn org_team_create(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let back = ctx.href("/teams"); let Some(role) = parse_team_role(&form.role) else { set_notice(cx, "invalid_role"); return Ok(see_other(back)); }; let description = form.description.trim(); if description.chars().count() > MAX_TEAM_DESCRIPTION_CHARS { set_notice(cx, "team_description_length"); return Ok(see_other(back)); } let all_repos = !form.all_repos.is_empty(); let team = match state .store .create_team(&ctx.org.id, form.name.trim(), description, role, all_repos) .await { Ok(team) => team, Err(e) => match team_error_notice(&e) { Some(word) => { set_notice(cx, word); return Ok(see_other(back)); } None => return Err(e.into()), }, }; audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamCreated, Target { kind: "team", id: &team.id, name: &team.name, }, serde_json::json!({ "org": ctx.org.username, "role": role.as_str(), "all_repos": all_repos, }), ) .await; set_notice(cx, "team_created"); Ok(see_other(ctx.href(&format!("/teams/{}", team.id)))) } /// Renames a team and changes its description, role and coverage. Owners only. #[route(POST "/org/{org}/teams/{team}/update")] async fn org_team_update(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let back = ctx.href(&format!("/teams/{}", team.id)); let Some(role) = parse_team_role(&form.role) else { set_notice(cx, "invalid_role"); return Ok(see_other(back)); }; let description = form.description.trim(); if description.chars().count() > MAX_TEAM_DESCRIPTION_CHARS { set_notice(cx, "team_description_length"); return Ok(see_other(back)); } let all_repos = !form.all_repos.is_empty(); match state .store .update_team(&team.id, form.name.trim(), description, role, all_repos) .await { Ok(()) => {} Err(e) => match team_error_notice(&e) { Some(word) => { set_notice(cx, word); return Ok(see_other(back)); } None => return Err(e.into()), }, } let name = form.name.trim().to_owned(); audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamUpdated, Target { kind: "team", id: &team.id, name: &name, }, serde_json::json!({ "role": role.as_str(), "all_repos": all_repos, }), ) .await; set_notice(cx, "team_updated"); Ok(see_other(back)) } /// Deletes a team. Owners only. #[route(POST "/org/{org}/teams/{team}/delete")] async fn org_team_delete(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let name = team.name.clone(); state.store.delete_team(&team.id).await?; audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamDeleted, Target { kind: "team", id: &team.id, name: &name, }, serde_json::Value::Null, ) .await; set_notice(cx, "team_deleted"); Ok(see_other(ctx.href("/teams"))) } /// Adds an organization member to a team. Owners only. #[route(POST "/org/{org}/teams/{team}/members/add")] async fn org_team_member_add(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let back = ctx.href(&format!("/teams/{}", team.id)); let username = form.username.trim(); if username.is_empty() { set_notice(cx, "empty_username"); return Ok(see_other(back)); } let Some(target) = state.store.user_by_username(username).await? else { set_notice(cx, "unknown_user"); return Ok(see_other(back)); }; if target.is_org() { set_notice(cx, "unknown_user"); return Ok(see_other(back)); } match state.store.add_team_member(&team.id, &target.id).await { Ok(()) => {} Err(forge_core::Error::Invalid(_)) => { set_notice(cx, "not_in_org"); return Ok(see_other(back)); } Err(forge_core::Error::Conflict(_)) => { set_notice(cx, "already_in_team"); return Ok(see_other(back)); } Err(e) => return Err(e.into()), } audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamMemberAdded, Target { kind: "team", id: &team.id, name: &team.name, }, serde_json::json!({ "user": target.username }), ) .await; set_notice(cx, "team_member_added"); Ok(see_other(back)) } /// Removes a person from a team. Owners only. #[route(POST "/org/{org}/teams/{team}/members/remove")] async fn org_team_member_remove(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let back = ctx.href(&format!("/teams/{}", team.id)); let user_id = form.user_id.trim(); if state.store.remove_team_member(&team.id, user_id).await? { audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamMemberRemoved, Target { kind: "team", id: &team.id, name: &team.name, }, serde_json::json!({ "user": user_id }), ) .await; set_notice(cx, "team_member_removed"); } Ok(see_other(back)) } /// Links one of the org's repos to a team. Owners only. #[route(POST "/org/{org}/teams/{team}/repos/add")] async fn org_team_repo_add(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let back = ctx.href(&format!("/teams/{}", team.id)); let repo_id = form.repo_id.trim(); match state.store.add_team_repo(&team.id, repo_id).await { Ok(()) => {} Err(forge_core::Error::Invalid(_)) => { set_notice(cx, "not_owned"); return Ok(see_other(back)); } Err(forge_core::Error::Conflict(_)) => { set_notice(cx, "already_linked"); return Ok(see_other(back)); } Err(e) => return Err(e.into()), } audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamRepoAdded, Target { kind: "team", id: &team.id, name: &team.name, }, serde_json::json!({ "repo_id": repo_id }), ) .await; set_notice(cx, "team_repo_added"); Ok(see_other(back)) } /// Unlinks a repo from a team. Owners only. #[route(POST "/org/{org}/teams/{team}/repos/remove")] async fn org_team_repo_remove(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_owner(&ctx)?; let state = app_context::(cx); let team = team_of(state, &ctx, path_param::(cx)).await?; let back = ctx.href(&format!("/teams/{}", team.id)); let repo_id = form.repo_id.trim(); if state.store.remove_team_repo(&team.id, repo_id).await? { audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::TeamRepoRemoved, Target { kind: "team", id: &team.id, name: &team.name, }, serde_json::json!({ "repo_id": repo_id }), ) .await; set_notice(cx, "team_repo_removed"); } Ok(see_other(back)) } #[cfg(test)] mod tests { use super::*; #[test] fn team_roles_parse_only_the_five_team_roles() { assert_eq!(parse_team_role("read"), Some(Role::Read)); assert_eq!(parse_team_role(" admin "), Some(Role::Admin)); assert_eq!(parse_team_role("owner"), None); assert_eq!(parse_team_role(""), None); } #[test] fn coverage_reads_in_words() { assert_eq!( g::coverage_text(Lang::En, "write", true), "write on all repositories" ); assert_eq!( g::coverage_text(Lang::En, "read", false), "read on linked repositories" ); } #[test] fn duplicate_and_bad_names_get_their_own_notices() { let dup = forge_core::Error::Conflict("taken".into()); let bad = forge_core::Error::InvalidName("bad".into()); let other = forge_core::Error::NotFound; assert_eq!(team_error_notice(&dup), Some("team_exists")); assert_eq!(team_error_notice(&bad), Some("invalid_name")); assert_eq!(team_error_notice(&other), None); } }