forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.7 KiB
1//! Page: organization members (`/org/{org}/members`). Members see the list. Owners add people by
2//! username, change their roles and remove them. Any member may leave.
3//!
4//! Rules:
5//! - The last owner cannot be demoted or removed (the store refuses; the page shows why).
6//! - Organizations are not members, and nobody is added twice.
7
8use forge_core::AuditAction;
9use forge_core::store::orgs::OrgMemberRole;
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, see_other};
15use topcoat::router::{page, path_param, route};
16use topcoat::view::{View, component, view};
17
18use crate::audit::{self, Actor, Target};
19use crate::i18n::orgs::{self as g, K};
20use crate::i18n::{self, Lang};
21use crate::pages::org_ctx::{
22 load_org, member_role_word, org_head, org_notice, parse_member_role, require_form_csrf,
23 require_member, require_owner, set_notice, take_notice,
24};
25use crate::state::AppState;
26use crate::views::{avatar_url, display_name, shell};
27
28path_param!(org);
29
30#[derive(Deserialize)]
31struct AddForm {
32 #[serde(default)]
33 csrf: String,
34 #[serde(default)]
35 username: String,
36 #[serde(default)]
37 role: String,
38}
39
40#[derive(Deserialize)]
41struct RoleForm {
42 #[serde(default)]
43 csrf: String,
44 #[serde(default)]
45 user_id: String,
46 #[serde(default)]
47 role: String,
48}
49
50#[derive(Deserialize)]
51struct RemoveForm {
52 #[serde(default)]
53 csrf: String,
54 #[serde(default)]
55 user_id: String,
56}
57
58/// One table row: a member, their org role, and whether it is the viewer.
59struct MemberRow {
60 user_id: String,
61 username: String,
62 /// What to show: the login name, or "Deleted user" for the ghost account.
63 label: String,
64 avatar: String,
65 is_owner: bool,
66 is_self: bool,
67}
68
69/// The members table and the add form. Owners get the role, remove and add controls.
70#[component]
71async fn members_body(
72 name: String,
73 description: String,
74 base: String,
75 owner: bool,
76 rows: Vec<MemberRow>,
77 csrf: String,
78 notice: Option<(&'static str, bool)>,
79 lang: Lang,
80) -> Result<impl View> {
81 let role_action = format!("{base}/members/role");
82 let remove_action = format!("{base}/members/remove");
83 let add_action = format!("{base}/members/add");
84 Ok(view! {
85 org_head(name: name, description: description, active: "members", is_owner: owner, lang: lang)
86 org_notice(notice: notice)
87 <section class="card">
88 <h2>(g::t(lang, K::Members))</h2>
89 <div class="table-wrap">
90 <table class="table collaborators">
91 <thead>
92 <tr>
93 <th>(g::t(lang, K::MemberHeader))</th>
94 <th>(g::t(lang, K::Role))</th>
95 <th><span class="muted small">(g::t(lang, K::Remove))</span></th>
96 </tr>
97 </thead>
98 <tbody>
99 for row in rows.iter() {
100 <tr>
101 <td>
102 <a class="user-link" href=(format!("/{}", row.username.as_str()))>
103 <img class="avatar avatar-sm" src=(row.avatar.as_str()) alt="" width="24" height="24">
104 <span>(row.label.as_str())</span>
105 </a>
106 </td>
107 <td>
108 if owner {
109 <form method="post" action=(role_action.as_str()) class="inline-form">
110 <input type="hidden" name="csrf" value=(csrf.as_str())>
111 <input type="hidden" name="user_id" value=(row.user_id.as_str())>
112 <select name="role" aria-label=(g::role_for(lang, row.username.as_str()))>
113 <option value="owner" selected=(row.is_owner)>(g::t(lang, K::RoleOwner))</option>
114 <option value="member" selected=(!row.is_owner)>(g::t(lang, K::RoleMember))</option>
115 </select>
116 <button type="submit">(g::t(lang, K::Save))</button>
117 </form>
118 } else {
119 <span>(if row.is_owner { g::t(lang, K::RoleOwner) } else { g::t(lang, K::RoleMember) })</span>
120 }
121 </td>
122 <td>
123 if owner || row.is_self {
124 <form method="post" action=(remove_action.as_str()) class="remove-form">
125 <input type="hidden" name="csrf" value=(csrf.as_str())>
126 <input type="hidden" name="user_id" value=(row.user_id.as_str())>
127 <button type="submit">(if row.is_self { g::t(lang, K::Leave) } else { g::t(lang, K::Remove) })</button>
128 </form>
129 }
130 </td>
131 </tr>
132 }
133 </tbody>
134 </table>
135 </div>
136 </section>
137 if owner {
138 <section class="card">
139 <h3>(g::t(lang, K::AddMember))</h3>
140 <form method="post" action=(add_action.as_str()) class="stack">
141 <input type="hidden" name="csrf" value=(csrf.as_str())>
142 <label>
143 <span>(g::t(lang, K::Username))</span>
144 <input type="text" name="username" autocomplete="off" spellcheck="false" required=(true)>
145 </label>
146 <label>
147 <span>(g::t(lang, K::Role))</span>
148 <select name="role">
149 <option value="member" selected=(true)>(g::t(lang, K::RoleMember))</option>
150 <option value="owner">(g::t(lang, K::RoleOwner))</option>
151 </select>
152 </label>
153 <p class="muted small">(g::t(lang, K::OwnersHelp))</p>
154 <div>
155 <button type="submit">(g::t(lang, K::AddMemberButton))</button>
156 </div>
157 </form>
158 </section>
159 }
160 })
161}
162
163/// Gets the members page. Non-members get 404.
164#[page("/org/{org}/members")]
165async fn org_members_page(cx: &Cx) -> Result<impl View> {
166 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
167 require_member(&ctx)?;
168 let lang = i18n::lang(cx, Some(&ctx.viewer.0));
169 let state = app_context::<AppState>(cx);
170 let members = state.store.org_members(&ctx.org.id).await?;
171 let rows: Vec<MemberRow> = members
172 .into_iter()
173 .map(|(user, role)| MemberRow {
174 is_self: user.id == ctx.viewer.0.id,
175 user_id: user.id.clone(),
176 avatar: avatar_url(&user.username, user.avatar_hash.as_deref()),
177 label: display_name(lang, &user.username),
178 username: user.username,
179 is_owner: role == OrgMemberRole::Owner,
180 })
181 .collect();
182 let owner = ctx.is_owner();
183 let notice = take_notice(cx, lang);
184 let title = g::page_title(lang, &ctx.org.username, K::Members);
185 let name = ctx.org.username.clone();
186 let description = ctx.org.about.clone();
187 let base = ctx.href("");
188 let csrf = ctx.viewer.1.csrf_token.clone();
189 Ok(view! { cx =>
190 shell(title: title.as_str(), viewer: Some(&ctx.viewer),
191 members_body(name: name, description: description, base: base, owner: owner,
192 rows: rows, csrf: csrf, notice: notice, lang: lang))
193 })
194}
195
196/// Adds a person as a member or owner. Owners only.
197#[route(POST "/org/{org}/members/add")]
198async fn org_members_add(cx: &Cx, Form(form): Form<AddForm>) -> Result<SeeOther> {
199 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
200 require_form_csrf(&ctx, &form.csrf)?;
201 require_owner(&ctx)?;
202 let state = app_context::<AppState>(cx);
203 let back = ctx.href("/members");
204
205 let Some(role) = parse_member_role(form.role.trim()) else {
206 set_notice(cx, "invalid_role");
207 return Ok(see_other(back));
208 };
209 let username = form.username.trim();
210 if username.is_empty() {
211 set_notice(cx, "empty_username");
212 return Ok(see_other(back));
213 }
214 let Some(target) = state.store.user_by_username(username).await? else {
215 set_notice(cx, "unknown_user");
216 return Ok(see_other(back));
217 };
218 if target.is_org() {
219 set_notice(cx, "unknown_user");
220 return Ok(see_other(back));
221 }
222 if state
223 .store
224 .org_member_role(&ctx.org.id, &target.id)
225 .await?
226 .is_some()
227 {
228 set_notice(cx, "already_member");
229 return Ok(see_other(back));
230 }
231
232 state
233 .store
234 .add_org_member(&ctx.org.id, &target.id, role)
235 .await?;
236 audit::record(
237 &state.store,
238 Some(cx),
239 Actor::User(&ctx.viewer.0),
240 AuditAction::OrgMemberAdded,
241 Target {
242 kind: "org",
243 id: &ctx.org.id,
244 name: &ctx.org.username,
245 },
246 serde_json::json!({ "user": target.username, "role": member_role_word(role) }),
247 )
248 .await;
249 set_notice(cx, "member_added");
250 Ok(see_other(back))
251}
252
253/// Changes a member's org role. Owners only. Demoting the last owner is refused.
254#[route(POST "/org/{org}/members/role")]
255async fn org_members_role(cx: &Cx, Form(form): Form<RoleForm>) -> Result<SeeOther> {
256 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
257 require_form_csrf(&ctx, &form.csrf)?;
258 require_owner(&ctx)?;
259 let state = app_context::<AppState>(cx);
260 let back = ctx.href("/members");
261
262 let Some(role) = parse_member_role(form.role.trim()) else {
263 set_notice(cx, "invalid_role");
264 return Ok(see_other(back));
265 };
266 let user_id = form.user_id.trim();
267 if state
268 .store
269 .org_member_role(&ctx.org.id, user_id)
270 .await?
271 .is_none()
272 {
273 set_notice(cx, "not_member");
274 return Ok(see_other(back));
275 }
276 match state
277 .store
278 .set_org_member_role(&ctx.org.id, user_id, role)
279 .await
280 {
281 Ok(()) => {}
282 Err(forge_core::Error::Conflict(_)) => {
283 set_notice(cx, "last_owner");
284 return Ok(see_other(back));
285 }
286 Err(e) => return Err(e.into()),
287 }
288 audit::record(
289 &state.store,
290 Some(cx),
291 Actor::User(&ctx.viewer.0),
292 AuditAction::OrgMemberRoleChanged,
293 Target {
294 kind: "org",
295 id: &ctx.org.id,
296 name: &ctx.org.username,
297 },
298 serde_json::json!({ "user": user_id, "role": member_role_word(role) }),
299 )
300 .await;
301 set_notice(cx, "role_updated");
302 Ok(see_other(back))
303}
304
305/// Removes a member. Owners may remove anyone; any member may remove themselves (leave). A
306/// leaving member is sent to the public org page, since the members page is no longer theirs.
307#[route(POST "/org/{org}/members/remove")]
308async fn org_members_remove(cx: &Cx, Form(form): Form<RemoveForm>) -> Result<SeeOther> {
309 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
310 require_form_csrf(&ctx, &form.csrf)?;
311 require_member(&ctx)?;
312 let state = app_context::<AppState>(cx);
313 let user_id = form.user_id.trim().to_owned();
314 let self_removal = user_id == ctx.viewer.0.id;
315 if !self_removal {
316 require_owner(&ctx)?;
317 }
318 let back = ctx.href("/members");
319
320 if state
321 .store
322 .org_member_role(&ctx.org.id, &user_id)
323 .await?
324 .is_none()
325 {
326 set_notice(cx, "not_member");
327 return Ok(see_other(back));
328 }
329 match state.store.remove_org_member(&ctx.org.id, &user_id).await {
330 Ok(()) => {}
331 Err(forge_core::Error::Conflict(_)) => {
332 set_notice(cx, "last_owner");
333 return Ok(see_other(back));
334 }
335 Err(e) => return Err(e.into()),
336 }
337 audit::record(
338 &state.store,
339 Some(cx),
340 Actor::User(&ctx.viewer.0),
341 AuditAction::OrgMemberRemoved,
342 Target {
343 kind: "org",
344 id: &ctx.org.id,
345 name: &ctx.org.username,
346 },
347 serde_json::json!({ "user": user_id, "self": self_removal }),
348 )
349 .await;
350 if self_removal {
351 return Ok(see_other(format!("/{}", ctx.org.username)));
352 }
353 set_notice(cx, "member_removed");
354 Ok(see_other(back))
355}