| 1 | //! A workspace's people: the directory of its members and teams, and each |
| 2 | //! member's place in it (a title and what they own). See |
| 3 | //! `g1t_contracts::people`. |
| 4 | |
| 5 | use g1t_contracts::audit::Surface; |
| 6 | use g1t_contracts::people::*; |
| 7 | use g1t_contracts::teams::{TeamRef, TeamRole}; |
| 8 | use g1t_contracts::{FailureCode, Outcome, Role}; |
| 9 | use serde::Deserialize; |
| 10 | use worker::Result; |
| 11 | use worker::wasm_bindgen::JsValue; |
| 12 | |
| 13 | use crate::Identity; |
| 14 | use crate::teams::{TeamRow, budget_of, channel_of, may_see, role_of}; |
| 15 | |
| 16 | const MEMBERS_ONLY: &str = "Only members can see who is in a workspace."; |
| 17 | const NO_SUCH_MEMBER: &str = "There is no member with that username."; |
| 18 | |
| 19 | /// What someone owns, from its stored JSON; nothing when it is missing or |
| 20 | /// not a list of words. |
| 21 | pub fn owns_from(json: Option<&str>) -> Vec<String> { |
| 22 | json.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok()) |
| 23 | .map(|owns| clean_owns(&owns)) |
| 24 | .unwrap_or_default() |
| 25 | } |
| 26 | |
| 27 | /// One member's row, as the directory reads it. |
| 28 | #[derive(Deserialize)] |
| 29 | struct PersonRow { |
| 30 | user_id: String, |
| 31 | username: String, |
| 32 | #[serde(default)] |
| 33 | display_username: Option<String>, |
| 34 | #[serde(default)] |
| 35 | name: Option<String>, |
| 36 | #[serde(default)] |
| 37 | avatar: Option<String>, |
| 38 | #[serde(default)] |
| 39 | bio: Option<String>, |
| 40 | #[serde(default)] |
| 41 | location: Option<String>, |
| 42 | #[serde(default)] |
| 43 | pronouns: Option<String>, |
| 44 | #[serde(default)] |
| 45 | timezone: Option<String>, |
| 46 | role: String, |
| 47 | #[serde(default)] |
| 48 | title: Option<String>, |
| 49 | #[serde(default)] |
| 50 | owns: Option<String>, |
| 51 | joined_at: String, |
| 52 | } |
| 53 | |
| 54 | impl PersonRow { |
| 55 | fn person(self) -> DirectoryPerson { |
| 56 | let blank = |value: Option<String>| value.filter(|value| !value.trim().is_empty()); |
| 57 | DirectoryPerson { |
| 58 | user_id: self.user_id, |
| 59 | display_username: self.display_username.filter(|shown| *shown != self.username), |
| 60 | username: self.username, |
| 61 | name: blank(self.name), |
| 62 | avatar: self.avatar, |
| 63 | bio: blank(self.bio), |
| 64 | location: blank(self.location), |
| 65 | pronouns: blank(self.pronouns), |
| 66 | timezone: blank(self.timezone), |
| 67 | role: if self.role == "owner" { Role::Owner } else { Role::Member }, |
| 68 | title: self.title.as_deref().and_then(clean_title), |
| 69 | owns: owns_from(self.owns.as_deref()), |
| 70 | joined_at: self.joined_at, |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | /// Every member of a workspace (by slug, `?1`), with `filter` added. |
| 76 | fn people_sql(filter: &str) -> String { |
| 77 | format!( |
| 78 | "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location, |
| 79 | u.pronouns, u.timezone, wm.role, wm.title, wm.owns, wm.created_at AS joined_at |
| 80 | FROM workspace_members wm |
| 81 | JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL |
| 82 | JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL |
| 83 | WHERE w.slug = ?1 {filter} |
| 84 | ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000" |
| 85 | ) |
| 86 | } |
| 87 | |
| 88 | #[derive(Deserialize)] |
| 89 | struct TeamPersonRow { |
| 90 | team_id: String, |
| 91 | username: String, |
| 92 | role: String, |
| 93 | } |
| 94 | |
| 95 | #[derive(Deserialize)] |
| 96 | struct BaseRow { |
| 97 | #[serde(default)] |
| 98 | base_permission: Option<String>, |
| 99 | } |
| 100 | |
| 101 | impl Identity { |
| 102 | pub async fn people_directory(&self, a: PeopleArgs) -> Result<Outcome<PeopleDirectory>> { |
| 103 | let workspace = a.workspace.trim().to_lowercase(); |
| 104 | let Some(viewer) = a.viewer.as_ref() else { |
| 105 | return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY)); |
| 106 | }; |
| 107 | let Some(role) = role_of(viewer, &workspace) else { |
| 108 | return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY)); |
| 109 | }; |
| 110 | let owner = role == Role::Owner; |
| 111 | let slug = JsValue::from(workspace.as_str()); |
| 112 | let mut found = self |
| 113 | .db |
| 114 | .batch(vec![ |
| 115 | self.db.prepare(people_sql("")).bind(std::slice::from_ref(&slug))?, |
| 116 | self.db |
| 117 | .prepare( |
| 118 | "SELECT tm.team_id, u.username, tm.role FROM team_members tm |
| 119 | JOIN teams t ON t.id = tm.team_id |
| 120 | JOIN workspaces w ON w.id = t.workspace_id |
| 121 | JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL |
| 122 | WHERE w.slug = ?1 ORDER BY tm.role DESC, u.username LIMIT 20000", |
| 123 | ) |
| 124 | .bind(std::slice::from_ref(&slug))?, |
| 125 | self.db |
| 126 | .prepare("SELECT base_permission FROM workspaces WHERE slug = ?1 AND deleted_at IS NULL") |
| 127 | .bind(std::slice::from_ref(&slug))?, |
| 128 | ]) |
| 129 | .await? |
| 130 | .into_iter(); |
| 131 | let mut next = || found.next().ok_or_else(|| worker::Error::RustError("a statement of the batch did not answer".into())); |
| 132 | let people = next()?.results::<PersonRow>()?; |
| 133 | let team_people = next()?.results::<TeamPersonRow>()?; |
| 134 | let base = next()?.results::<BaseRow>()?; |
| 135 | let rows = self.workspace_team_rows(&workspace, &viewer.id).await?; |
| 136 | let teams = rows |
| 137 | .iter() |
| 138 | .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some())) |
| 139 | .map(|row| DirectoryTeam { |
| 140 | slug: row.slug.clone(), |
| 141 | name: row.name.clone(), |
| 142 | description: row.description.clone(), |
| 143 | visibility: row.visibility(), |
| 144 | parent: match (&row.parent_slug, &row.parent_name) { |
| 145 | (Some(slug), Some(name)) => Some(TeamRef { |
| 146 | slug: slug.clone(), |
| 147 | name: name.clone(), |
| 148 | }), |
| 149 | _ => None, |
| 150 | }, |
| 151 | lead: row.lead(), |
| 152 | channel: channel_of(row.channel_id.as_deref(), row.channel_name.as_deref()), |
| 153 | budget_micros: budget_of(row.budget_micros), |
| 154 | people: team_people |
| 155 | .iter() |
| 156 | .filter(|person| person.team_id == row.id) |
| 157 | .map(|person| TeamPersonRef { |
| 158 | username: person.username.clone(), |
| 159 | role: TeamRole::parse(&person.role).unwrap_or(TeamRole::Member), |
| 160 | }) |
| 161 | .collect(), |
| 162 | agent_ids: Vec::new(), |
| 163 | repos_count: row.repos_count, |
| 164 | }) |
| 165 | .collect(); |
| 166 | Ok(Outcome::Ok(PeopleDirectory { |
| 167 | people: people.into_iter().map(PersonRow::person).collect(), |
| 168 | teams, |
| 169 | base_permission: base |
| 170 | .into_iter() |
| 171 | .next() |
| 172 | .and_then(|row| row.base_permission) |
| 173 | .filter(|base| !base.trim().is_empty()) |
| 174 | .unwrap_or_else(|| "write".to_owned()), |
| 175 | can_manage: owner, |
| 176 | })) |
| 177 | } |
| 178 | |
| 179 | pub async fn set_member_profile(&self, a: SetMemberProfileArgs) -> Result<Outcome<DirectoryPerson>> { |
| 180 | let workspace = a.workspace.trim().to_lowercase(); |
| 181 | if !crate::security::is_person(&a.actor) { |
| 182 | return Ok(Outcome::fail( |
| 183 | FailureCode::Forbidden, |
| 184 | "Only a person can change a profile, signed in as themselves.", |
| 185 | )); |
| 186 | } |
| 187 | let Some(role) = role_of(&a.actor, &workspace) else { |
| 188 | return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY)); |
| 189 | }; |
| 190 | if !a.actor.verified { |
| 191 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first.")); |
| 192 | } |
| 193 | let owner = role == Role::Owner; |
| 194 | let Some(workspace_id) = self.workspace_id_of(&workspace).await? else { |
| 195 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 196 | }; |
| 197 | let Some((user_id, username)) = self.person_by_username(&a.username).await? else { |
| 198 | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER)); |
| 199 | }; |
| 200 | if !self.is_member_of(&workspace_id, &user_id).await? { |
| 201 | return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER)); |
| 202 | } |
| 203 | let is_self = a.actor.id == user_id; |
| 204 | let mut sets: Vec<String> = Vec::new(); |
| 205 | let mut binds: Vec<JsValue> = Vec::new(); |
| 206 | let mut changed: Vec<&str> = Vec::new(); |
| 207 | let refused = |what: &str| { |
| 208 | Ok(Outcome::fail( |
| 209 | FailureCode::Forbidden, |
| 210 | format!("Only owners of {workspace} can change {what} for someone else."), |
| 211 | )) |
| 212 | }; |
| 213 | if let Some(title) = &a.title { |
| 214 | if !may_edit_profile(ProfileField::Title, is_self, owner) { |
| 215 | return refused("a title"); |
| 216 | } |
| 217 | binds.push(clean_title(title).map_or(JsValue::NULL, JsValue::from)); |
| 218 | sets.push(format!("title = ?{}", binds.len())); |
| 219 | changed.push("title"); |
| 220 | } |
| 221 | if let Some(owns) = &a.owns { |
| 222 | if !may_edit_profile(ProfileField::Owns, is_self, owner) { |
| 223 | return refused("what someone owns"); |
| 224 | } |
| 225 | let owns = clean_owns(owns); |
| 226 | binds.push(if owns.is_empty() { JsValue::NULL } else { serde_json::to_string(&owns)?.into() }); |
| 227 | sets.push(format!("owns = ?{}", binds.len())); |
| 228 | changed.push("what they own"); |
| 229 | } |
| 230 | if !sets.is_empty() { |
| 231 | binds.push(JsValue::from(workspace_id.clone())); |
| 232 | binds.push(JsValue::from(user_id.clone())); |
| 233 | self.db |
| 234 | .prepare(format!( |
| 235 | "UPDATE workspace_members SET {} WHERE workspace_id = ?{} AND user_id = ?{}", |
| 236 | sets.join(", "), |
| 237 | binds.len() - 1, |
| 238 | binds.len() |
| 239 | )) |
| 240 | .bind(&binds)? |
| 241 | .run() |
| 242 | .await?; |
| 243 | self.audit_workspace( |
| 244 | &a.actor, |
| 245 | "member.profile_edited", |
| 246 | &workspace, |
| 247 | a.surface.unwrap_or(Surface::Web), |
| 248 | format!("Changed {} for {username}", changed.join(", ")), |
| 249 | ) |
| 250 | .await; |
| 251 | } |
| 252 | let person = self |
| 253 | .db |
| 254 | .prepare(people_sql("AND u.id = ?2")) |
| 255 | .bind(&[workspace.as_str().into(), user_id.as_str().into()])? |
| 256 | .first::<PersonRow>(None) |
| 257 | .await?; |
| 258 | Ok(match person { |
| 259 | Some(person) => Outcome::Ok(person.person()), |
| 260 | None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER), |
| 261 | }) |
| 262 | } |
| 263 | |
| 264 | /// Every team of a workspace, as `viewer_id` stands in each. |
| 265 | async fn workspace_team_rows(&self, workspace: &str, viewer_id: &str) -> Result<Vec<TeamRow>> { |
| 266 | self.team_rows("WHERE w.slug = ?2 ORDER BY lower(t.name)", &[viewer_id.into(), workspace.into()]) |
| 267 | .await |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | #[cfg(test)] |
| 272 | mod tests { |
| 273 | use super::*; |
| 274 | |
| 275 | #[test] |
| 276 | fn what_someone_owns_reads_back_from_storage() { |
| 277 | assert_eq!(owns_from(Some(r#"["storefront"," Storefront ","releases"]"#)), vec!["storefront", "releases"]); |
| 278 | assert!(owns_from(Some("not json")).is_empty()); |
| 279 | assert!(owns_from(None).is_empty()); |
| 280 | } |
| 281 | } |