//! Shared pieces of the organization pages (`/org/{name}/...`): loading the org for a request and //! the signed-in viewer's role in it, the header tabs, the role words, and the one-shot notices //! shown after a redirect. //! //! Visibility rules: a non-member gets 404 on every org page below the profile, so a private org's //! membership is not revealed. A member who is not an owner gets 403 on the owner-only actions. use forge_core::store::orgs::OrgMemberRole; use forge_core::{Role, Session, User}; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::cookie::{Cookie, Cookies, SameSite, cookies}; use topcoat::router::error::{forbidden, not_found}; use topcoat::view::{View, component, view}; use crate::auth::{check_csrf, require_user}; use crate::i18n::Lang; use crate::i18n::orgs::{self as g, K}; use crate::state::AppState; /// Cookie that carries a one-shot notice to the next org page. Its value is one of the fixed /// words in `NOTICES`, never user text. pub const NOTICE_COOKIE: &str = "forge_org_notice"; /// Notice words, the text key each one shows, and whether it is an error. pub const NOTICES: &[(&str, K, bool)] = &[ ("settings_saved", K::NoticeSettingsSaved, false), ("member_added", K::NoticeMemberAdded, false), ("role_updated", K::NoticeRoleUpdated, false), ("member_removed", K::NoticeMemberRemoved, false), ("team_created", K::NoticeTeamCreated, false), ("team_updated", K::NoticeTeamSaved, false), ("team_deleted", K::NoticeTeamDeleted, false), ("team_member_added", K::NoticeTeamMemberAdded, false), ("team_member_removed", K::NoticeTeamMemberRemoved, false), ("team_repo_added", K::NoticeTeamRepoAdded, false), ("team_repo_removed", K::NoticeTeamRepoRemoved, false), ("empty_username", K::NoticeEmptyUsername, true), ("unknown_user", K::NoticeUnknownUser, true), ("already_member", K::NoticeAlreadyMember, true), ("not_member", K::NoticeNotMember, true), ("last_owner", K::NoticeLastOwner, true), ("invalid_role", K::NoticeInvalidRole, true), ("invalid_name", K::NoticeInvalidName, true), ( "team_description_length", K::NoticeTeamDescriptionLength, true, ), ("team_exists", K::NoticeTeamExists, true), ("already_in_team", K::NoticeAlreadyInTeam, true), ("not_in_org", K::NoticeNotInOrg, true), ("not_owned", K::NoticeNotOwned, true), ("already_linked", K::NoticeAlreadyLinked, true), ("has_repos", K::NoticeHasRepos, true), ("confirm_name", K::NoticeConfirmName, true), ("description_length", K::NoticeDescriptionLength, true), ]; /// Roles a team can carry, in the order the selects show them. pub const TEAM_ROLES: [Role; 5] = [ Role::Read, Role::Triage, Role::Write, Role::Maintain, Role::Admin, ]; /// Default repo roles for the org settings select. pub const DEFAULT_ROLES: [&str; 3] = ["none", "read", "write"]; /// The org a request is about, and what the viewer may do in it. pub struct OrgCtx { pub org: User, pub viewer: (User, Session), /// The viewer's org role; `None` when they are not a member. pub role: Option, } impl OrgCtx { pub fn is_member(&self) -> bool { self.role.is_some() } pub fn is_owner(&self) -> bool { self.role == Some(OrgMemberRole::Owner) } /// `/org/{name}{rest}`, for redirects and form actions. pub fn href(&self, rest: &str) -> String { format!("/org/{}{rest}", self.org.username) } } /// Loads the org named `name` for the signed-in viewer. Anonymous viewers go to login. Missing /// names and people (anything that is not an org) are 404. pub async fn load_org(cx: &Cx, name: &str) -> Result { let viewer = require_user(cx).await?; let state = app_context::(cx); let Some(org) = state.store.user_by_username(name).await? else { return Err(not_found().into()); }; if !org.is_org() { return Err(not_found().into()); } let role = state.store.org_member_role(&org.id, &viewer.0.id).await?; Ok(OrgCtx { org, viewer, role }) } /// 404 unless the viewer is a member of the org. pub fn require_member(ctx: &OrgCtx) -> Result<()> { if ctx.is_member() { Ok(()) } else { Err(not_found().into()) } } /// 404 for non-members, 403 for members who are not owners. pub fn require_owner(ctx: &OrgCtx) -> Result<()> { require_member(ctx)?; if ctx.is_owner() { Ok(()) } else { Err(forbidden().into()) } } /// 403 unless the submitted CSRF token matches the viewer's session. pub fn require_form_csrf(ctx: &OrgCtx, submitted: &str) -> Result<()> { if check_csrf(&ctx.viewer.1, submitted) { Ok(()) } else { Err(forbidden().into()) } } /// Whether `user_id` may create a repo owned by the org `org_id`: an owner always may, a member /// only when the org allows members to create repos. pub async fn may_create_repo_in(state: &AppState, org_id: &str, user_id: &str) -> Result { match state.store.org_member_role(org_id, user_id).await? { Some(OrgMemberRole::Owner) => Ok(true), Some(OrgMemberRole::Member) => Ok(state .store .org_settings(org_id) .await? .members_can_create_repos), None => Ok(false), } } /// The stored word for a member role. pub fn member_role_word(role: OrgMemberRole) -> &'static str { role.as_str() } /// The member role a form sent, if it is one of the two. pub fn parse_member_role(word: &str) -> Option { OrgMemberRole::parse(word).ok() } /// Sets the notice shown on the next org page view. pub 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, in the viewer's language. pub fn take_notice(cx: &Cx, lang: Lang) -> Option<(&'static str, 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)| (g::t(lang, *key), *is_error)) } /// The org's name, description and tabs. `active` is `members`, `teams` or `settings`. #[component] pub async fn org_head( name: String, description: String, active: &'static str, is_owner: bool, lang: Lang, ) -> Result { let members_href = format!("/org/{name}/members"); let teams_href = format!("/org/{name}/teams"); let settings_href = format!("/org/{name}/settings"); let members_tab = active == "members"; let teams_tab = active == "teams"; let settings_tab = active == "settings"; Ok(view! {

(name.as_str())

if !description.is_empty() {

(description.as_str())

}
}) } /// The notice banner: an error alert, or a status line. #[component] pub async fn org_notice(notice: Option<(&'static str, bool)>) -> Result { Ok(view! { if let Some((text, is_error)) = notice { if is_error { } else {
(text)
} } }) } #[cfg(test)] mod tests { use super::*; #[test] fn every_notice_word_is_unique() { for (i, (word, _, _)) in NOTICES.iter().enumerate() { assert!( !NOTICES[i + 1..].iter().any(|(other, _, _)| other == word), "{word} is listed twice" ); } } }