//! Page: organization members (`/org/{org}/members`). Members see the list. Owners add people by //! username, change their roles and remove them. Any member may leave. //! //! Rules: //! - The last owner cannot be demoted or removed (the store refuses; the page shows why). //! - Organizations are not members, and nobody is added twice. use forge_core::AuditAction; use forge_core::store::orgs::OrgMemberRole; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, 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::{ load_org, member_role_word, org_head, org_notice, parse_member_role, require_form_csrf, require_member, require_owner, set_notice, take_notice, }; use crate::state::AppState; use crate::views::{avatar_url, display_name, shell}; path_param!(org); #[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 member, their org role, and whether it is the viewer. struct MemberRow { user_id: String, username: String, /// What to show: the login name, or "Deleted user" for the ghost account. label: String, avatar: String, is_owner: bool, is_self: bool, } /// The members table and the add form. Owners get the role, remove and add controls. #[component] async fn members_body( name: String, description: String, base: String, owner: bool, rows: Vec, csrf: String, notice: Option<(&'static str, bool)>, lang: Lang, ) -> Result { let role_action = format!("{base}/members/role"); let remove_action = format!("{base}/members/remove"); let add_action = format!("{base}/members/add"); Ok(view! { org_head(name: name, description: description, active: "members", is_owner: owner, lang: lang) org_notice(notice: notice)

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

for row in rows.iter() { }
(g::t(lang, K::MemberHeader)) (g::t(lang, K::Role)) (g::t(lang, K::Remove))
(row.label.as_str()) if owner {
} else { (if row.is_owner { g::t(lang, K::RoleOwner) } else { g::t(lang, K::RoleMember) }) }
if owner || row.is_self {
}
if owner {

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

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

} }) } /// Gets the members page. Non-members get 404. #[page("/org/{org}/members")] async fn org_members_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 members = state.store.org_members(&ctx.org.id).await?; let rows: Vec = members .into_iter() .map(|(user, role)| MemberRow { is_self: user.id == ctx.viewer.0.id, user_id: user.id.clone(), avatar: avatar_url(&user.username, user.avatar_hash.as_deref()), label: display_name(lang, &user.username), username: user.username, is_owner: role == OrgMemberRole::Owner, }) .collect(); let owner = ctx.is_owner(); let notice = take_notice(cx, lang); let title = g::page_title(lang, &ctx.org.username, K::Members); 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), members_body(name: name, description: description, base: base, owner: owner, rows: rows, csrf: csrf, notice: notice, lang: lang)) }) } /// Adds a person as a member or owner. Owners only. #[route(POST "/org/{org}/members/add")] async fn org_members_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 back = ctx.href("/members"); let Some(role) = parse_member_role(form.role.trim()) else { set_notice(cx, "invalid_role"); return Ok(see_other(back)); }; 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)); } if state .store .org_member_role(&ctx.org.id, &target.id) .await? .is_some() { set_notice(cx, "already_member"); return Ok(see_other(back)); } state .store .add_org_member(&ctx.org.id, &target.id, role) .await?; audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::OrgMemberAdded, Target { kind: "org", id: &ctx.org.id, name: &ctx.org.username, }, serde_json::json!({ "user": target.username, "role": member_role_word(role) }), ) .await; set_notice(cx, "member_added"); Ok(see_other(back)) } /// Changes a member's org role. Owners only. Demoting the last owner is refused. #[route(POST "/org/{org}/members/role")] async fn org_members_role(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("/members"); let Some(role) = parse_member_role(form.role.trim()) else { set_notice(cx, "invalid_role"); return Ok(see_other(back)); }; let user_id = form.user_id.trim(); if state .store .org_member_role(&ctx.org.id, user_id) .await? .is_none() { set_notice(cx, "not_member"); return Ok(see_other(back)); } match state .store .set_org_member_role(&ctx.org.id, user_id, role) .await { Ok(()) => {} Err(forge_core::Error::Conflict(_)) => { set_notice(cx, "last_owner"); return Ok(see_other(back)); } Err(e) => return Err(e.into()), } audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::OrgMemberRoleChanged, Target { kind: "org", id: &ctx.org.id, name: &ctx.org.username, }, serde_json::json!({ "user": user_id, "role": member_role_word(role) }), ) .await; set_notice(cx, "role_updated"); Ok(see_other(back)) } /// Removes a member. Owners may remove anyone; any member may remove themselves (leave). A /// leaving member is sent to the public org page, since the members page is no longer theirs. #[route(POST "/org/{org}/members/remove")] async fn org_members_remove(cx: &Cx, Form(form): Form) -> Result { let ctx = load_org(cx, path_param::(cx)).await?; require_form_csrf(&ctx, &form.csrf)?; require_member(&ctx)?; let state = app_context::(cx); let user_id = form.user_id.trim().to_owned(); let self_removal = user_id == ctx.viewer.0.id; if !self_removal { require_owner(&ctx)?; } let back = ctx.href("/members"); if state .store .org_member_role(&ctx.org.id, &user_id) .await? .is_none() { set_notice(cx, "not_member"); return Ok(see_other(back)); } match state.store.remove_org_member(&ctx.org.id, &user_id).await { Ok(()) => {} Err(forge_core::Error::Conflict(_)) => { set_notice(cx, "last_owner"); return Ok(see_other(back)); } Err(e) => return Err(e.into()), } audit::record( &state.store, Some(cx), Actor::User(&ctx.viewer.0), AuditAction::OrgMemberRemoved, Target { kind: "org", id: &ctx.org.id, name: &ctx.org.username, }, serde_json::json!({ "user": user_id, "self": self_removal }), ) .await; if self_removal { return Ok(see_other(format!("/{}", ctx.org.username))); } set_notice(cx, "member_removed"); Ok(see_other(back)) }