Skip to content
281 linesCodeBlameRaw
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
5use g1t_contracts::audit::Surface;
6use g1t_contracts::people::*;
7use g1t_contracts::teams::{TeamRef, TeamRole};
8use g1t_contracts::{FailureCode, Outcome, Role};
9use serde::Deserialize;
10use worker::Result;
11use worker::wasm_bindgen::JsValue;
12
13use crate::Identity;
14use crate::teams::{TeamRow, budget_of, channel_of, may_see, role_of};
15
16const MEMBERS_ONLY: &str = "Only members can see who is in a workspace.";
17const 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.
21pub 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)]
29struct 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
54impl 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.
76fn 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)]
89struct TeamPersonRow {
90 team_id: String,
91 username: String,
92 role: String,
93}
94
95#[derive(Deserialize)]
96struct BaseRow {
97 #[serde(default)]
98 base_permission: Option<String>,
99}
100
101impl 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)]
272mod 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}