forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.6 KiB
1//! Page: collaborators (`/{owner}/{repo}/settings/collaborators`). Anyone who may manage the repo
2//! (the owner, an instance admin, or a collaborator with the Admin role) sees it; everyone else gets
3//! 403, and hidden repos stay 404 through `load_repo`.
4//!
5//! Rules:
6//! - The owner cannot be added as a collaborator.
7//! - Adding an existing collaborator updates their role.
8//! - Only the owner and instance admins may grant the Admin role, change an existing Admin's role,
9//! or remove an Admin. Other Admin collaborators get 403 for those actions.
10//! - Collaborators may remove themselves. A self-removal redirects to the repo home page instead,
11//! because the settings page is no longer visible to them and a notice there would be confusing.
12
13use forge_core::store::orgs::OrgMemberRole;
14use forge_core::{AuditAction, Role, User};
15use serde::Deserialize;
16use topcoat::Result;
17use topcoat::context::{Cx, app_context};
18use topcoat::cookie::{Cookie, Cookies, SameSite, cookies};
19use topcoat::router::content::Form;
20use topcoat::router::error::{SeeOther, forbidden, see_other};
21use topcoat::router::{page, path_param, route};
22use topcoat::view::{View, component, view};
23
24use crate::audit::{self, Actor, Target};
25use crate::auth::{require_csrf, require_user};
26use crate::i18n::repo_admin::{self as g, K};
27use crate::i18n::{self, Lang};
28use crate::pages::repo::repo_head;
29use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
30use crate::state::AppState;
31use crate::views::{alert, avatar_url, display_name, shell};
32
33path_param!(owner);
34path_param!(repo);
35
36/// Cookie that carries a one-shot notice to the next collaborators page. Its value is one of the
37/// fixed words in `NOTICES`, never user text.
38const NOTICE_COOKIE: &str = "forge_collab_notice";
39
40/// Notice words, the text each one shows, and whether it is an error.
41const NOTICES: [(&str, K, bool); 8] = [
42 ("added", K::CollabAdded, false),
43 ("updated", K::RoleUpdated, false),
44 ("removed", K::CollabRemoved, false),
45 ("empty_username", K::EnterUsername, true),
46 ("unknown_user", K::UnknownUser, true),
47 ("owner", K::OwnerIsMember, true),
48 ("invalid_role", K::InvalidRole, true),
49 ("not_collaborator", K::NotCollaborator, true),
50];
51
52/// Roles in the order the selects and the legend show them.
53const ROLES: [Role; 5] = [
54 Role::Read,
55 Role::Triage,
56 Role::Write,
57 Role::Maintain,
58 Role::Admin,
59];
60
61/// What each role may do, for the legend.
62const LEGEND: [(Role, K); 5] = [
63 (Role::Read, K::RoleReadDesc),
64 (Role::Triage, K::RoleTriageDesc),
65 (Role::Write, K::RoleWriteDesc),
66 (Role::Maintain, K::RoleMaintainDesc),
67 (Role::Admin, K::RoleAdminDesc),
68];
69
70#[derive(Deserialize)]
71struct AddForm {
72 #[serde(default)]
73 csrf: String,
74 #[serde(default)]
75 username: String,
76 #[serde(default)]
77 role: String,
78}
79
80#[derive(Deserialize)]
81struct RoleForm {
82 #[serde(default)]
83 csrf: String,
84 #[serde(default)]
85 user_id: String,
86 #[serde(default)]
87 role: String,
88}
89
90#[derive(Deserialize)]
91struct RemoveForm {
92 #[serde(default)]
93 csrf: String,
94 #[serde(default)]
95 user_id: String,
96}
97
98/// One table row: a collaborator and their current role.
99struct CollabRow {
100 user_id: String,
101 username: String,
102 /// What to show: the login name, or "Deleted user" for the ghost account.
103 label: String,
104 avatar: String,
105 role: Role,
106}
107
108/// Sets the notice shown on the next collaborators page view.
109fn set_notice(cx: &Cx, word: &'static str) {
110 cookies(cx).add(
111 Cookie::build((NOTICE_COOKIE, word))
112 .path("/")
113 .http_only(true)
114 .same_site(SameSite::Lax)
115 .build(),
116 );
117}
118
119/// Reads and clears the notice cookie. Only the fixed words are shown.
120fn take_notice(cx: &Cx) -> Option<(K, bool)> {
121 let jar = cookies(cx);
122 let value = jar.get(NOTICE_COOKIE)?.value().to_owned();
123 jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/"));
124 NOTICES
125 .iter()
126 .find(|(word, _, _)| *word == value)
127 .map(|(_, key, is_error)| (*key, *is_error))
128}
129
130/// Whether the actor may grant or change the Admin role: an instance admin, the owner of a
131/// person-owned repo, or an owner of the org that owns the repo.
132fn privileged(ctx: &RepoCtx, actor: &User, org_owner: bool) -> bool {
133 actor.is_admin || actor.id == ctx.repo.owner_id || org_owner
134}
135
136/// Whether `actor` owns the organization that owns the repo.
137async fn owns_repo_org(state: &AppState, ctx: &RepoCtx, actor: &User) -> Result<bool> {
138 Ok(matches!(
139 state
140 .store
141 .org_member_role(&ctx.repo.owner_id, &actor.id)
142 .await?,
143 Some(OrgMemberRole::Owner)
144 ))
145}
146
147/// `Owner/name` for redirects and form actions.
148fn title_of(ctx: &RepoCtx) -> String {
149 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
150}
151
152fn collaborators_redirect(title: &str) -> SeeOther {
153 see_other(format!("/{title}/settings/collaborators"))
154}
155
156/// The role legend: what each role may do.
157#[component]
158async fn role_legend(lang: Lang) -> Result<impl View> {
159 Ok(view! {
160 <section class="card">
161 <h3>(g::t(lang, K::Roles))</h3>
162 <dl class="role-list">
163 for entry in LEGEND.iter() {
164 <dt>(entry.0.as_str())</dt>
165 <dd>(g::t(lang, entry.1))</dd>
166 }
167 </dl>
168 </section>
169 })
170}
171
172/// The collaborators table. Each row has a role form (Save) and a remove form (Remove).
173#[component]
174async fn collaborators_table(
175 lang: Lang,
176 rows: Vec<CollabRow>,
177 role_action: String,
178 remove_action: String,
179 csrf: String,
180) -> Result<impl View> {
181 Ok(view! {
182 if rows.is_empty() {
183 <p class="muted">(g::t(lang, K::NoCollaborators))</p>
184 } else {
185 <div class="table-wrap">
186 <table class="table collaborators">
187 <thead>
188 <tr>
189 <th>(g::t(lang, K::ColUser))</th>
190 <th>(g::t(lang, K::Role))</th>
191 <th><span class="muted small">(g::t(lang, K::Remove))</span></th>
192 </tr>
193 </thead>
194 <tbody>
195 for row in rows.iter() {
196 <tr>
197 <td>
198 if row.label == row.username {
199 <a class="user-link" href=(format!("/{}", row.username.as_str()))>
200 <img class="avatar avatar-sm" src=(row.avatar.as_str()) alt="" width="24" height="24">
201 <span>(row.label.as_str())</span>
202 </a>
203 } else {
204 <span class="user-link">
205 <img class="avatar avatar-sm" src=(row.avatar.as_str()) alt="" width="24" height="24">
206 <span>(row.label.as_str())</span>
207 </span>
208 }
209 </td>
210 <td>
211 <form method="post" action=(role_action.as_str()) class="inline-form">
212 <input type="hidden" name="csrf" value=(csrf.as_str())>
213 <input type="hidden" name="user_id" value=(row.user_id.as_str())>
214 <select name="role" aria-label=(g::role_for(lang, row.username.as_str()))>
215 for role in ROLES.iter() {
216 <option value=(role.as_str()) selected=(*role == row.role)>(role.as_str())</option>
217 }
218 </select>
219 <button type="submit">(g::t(lang, K::SaveRole))</button>
220 </form>
221 </td>
222 <td>
223 <form method="post" action=(remove_action.as_str()) class="remove-form">
224 <input type="hidden" name="csrf" value=(csrf.as_str())>
225 <input type="hidden" name="user_id" value=(row.user_id.as_str())>
226 <button type="submit">(g::t(lang, K::Remove))</button>
227 </form>
228 </td>
229 </tr>
230 }
231 </tbody>
232 </table>
233 </div>
234 }
235 })
236}
237
238/// The add form: a username and a role. Re-adding an existing collaborator updates the role.
239#[component]
240async fn add_form(lang: Lang, add_action: String, csrf: String) -> Result<impl View> {
241 Ok(view! {
242 <h3>(g::t(lang, K::AddCollaboratorHeading))</h3>
243 <form method="post" action=(add_action.as_str()) class="stack">
244 <input type="hidden" name="csrf" value=(csrf.as_str())>
245 <label>
246 <span>(g::t(lang, K::Username))</span>
247 <input type="text" name="username" autocomplete="off" spellcheck="false" required=(true)>
248 </label>
249 <label>
250 <span>(g::t(lang, K::Role))</span>
251 <select name="role">
252 for role in ROLES.iter() {
253 <option value=(role.as_str()) selected=(*role == Role::Read)>(role.as_str())</option>
254 }
255 </select>
256 </label>
257 <p class="muted small">(g::t(lang, K::AddCollaboratorHelp))</p>
258 <div>
259 <button type="submit" class="primary">(g::t(lang, K::AddCollaboratorButton))</button>
260 </div>
261 </form>
262 })
263}
264
265/// Everything the collaborators page shows besides the repo header.
266struct FrameData {
267 page_title: String,
268 title: String,
269 head: RepoHead,
270}
271
272impl FrameData {
273 async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result<Self> {
274 let title = title_of(ctx);
275 Ok(Self {
276 page_title: g::collaborators_page_title(lang, &title),
277 title,
278 head: ctx.head(state, "settings").await?,
279 })
280 }
281}
282
283/// The page body: repo header with Settings active, the notice, the table, the legend and the add form.
284#[component]
285async fn collaborators_body(
286 lang: Lang,
287 frame: FrameData,
288 rows: Vec<CollabRow>,
289 csrf: String,
290 notice: Option<(K, bool)>,
291) -> Result<impl View> {
292 let role_action = format!("/{}/settings/collaborators/role", frame.title);
293 let remove_action = format!("/{}/settings/collaborators/remove", frame.title);
294 let add_action = format!("/{}/settings/collaborators/add", frame.title);
295 let header = frame.head;
296 let notice = notice.map(|(key, is_error)| (g::t(lang, key), is_error));
297 Ok(view! {
298 repo_head(head: header)
299 if let Some((text, is_error)) = notice {
300 if is_error {
301 alert(message: text)
302 } else {
303 <section class="card notice" role="status">(text)</section>
304 }
305 }
306 <section class="card">
307 <h2>(g::t(lang, K::Collaborators))</h2>
308 <p class="muted small">(g::t(lang, K::CollaboratorsHelp))</p>
309 collaborators_table(lang: lang, rows: rows, role_action: role_action,
310 remove_action: remove_action, csrf: csrf.clone())
311 add_form(lang: lang, add_action: add_action, csrf: csrf)
312 </section>
313 role_legend(lang: lang)
314 })
315}
316
317/// Gets the collaborators page. Hidden repos are 404 (`load_repo`), anonymous viewers go to login,
318/// and signed-in viewers who may not manage the repo get 403.
319#[page("/{owner}/{repo}/settings/collaborators")]
320async fn collaborators_page(cx: &Cx) -> Result<impl View> {
321 let owner = path_param::<Owner>(cx);
322 let name = path_param::<Repo>(cx);
323 let ctx = load_repo(cx, owner, name).await?;
324 let (user, session) = require_user(cx).await?;
325 if !ctx.can_admin() {
326 return Err(forbidden().into());
327 }
328 let lang = i18n::lang(cx, Some(&user));
329 let state = app_context::<AppState>(cx);
330 let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;
331 let rows = collaborators
332 .into_iter()
333 .map(|(user, role)| CollabRow {
334 user_id: user.id.clone(),
335 avatar: avatar_url(&user.username, user.avatar_hash.as_deref()),
336 label: display_name(lang, &user.username),
337 username: user.username,
338 role,
339 })
340 .collect();
341 let notice = take_notice(cx);
342 let frame = FrameData::new(&ctx, state, lang).await?;
343 let page_title = frame.page_title.clone();
344 let csrf = session.csrf_token.clone();
345 Ok(view! {
346 shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(),
347 collaborators_body(lang: lang, frame: frame, rows: rows, csrf: csrf, notice: notice))
348 })
349}
350
351/// Adds a collaborator or updates an existing one's role. Admin grants need the owner or an instance admin.
352#[route(POST "/{owner}/{repo}/settings/collaborators/add")]
353async fn collaborators_add(cx: &Cx, Form(form): Form<AddForm>) -> Result<SeeOther> {
354 let owner = path_param::<Owner>(cx);
355 let name = path_param::<Repo>(cx);
356 let ctx = load_repo(cx, owner, name).await?;
357 let (actor, _) = require_csrf(cx, &form.csrf).await?;
358 if !ctx.can_admin() {
359 return Err(forbidden().into());
360 }
361 let title = title_of(&ctx);
362 let state = app_context::<AppState>(cx);
363
364 let Ok(role) = form.role.trim().parse::<Role>() else {
365 set_notice(cx, "invalid_role");
366 return Ok(collaborators_redirect(&title));
367 };
368 let username = form.username.trim();
369 if username.is_empty() {
370 set_notice(cx, "empty_username");
371 return Ok(collaborators_redirect(&title));
372 }
373 let Some(target) = state.store.user_by_username(username).await? else {
374 set_notice(cx, "unknown_user");
375 return Ok(collaborators_redirect(&title));
376 };
377 // An organization is not a collaborator: it never acts on a repo.
378 if target.is_org() {
379 set_notice(cx, "unknown_user");
380 return Ok(collaborators_redirect(&title));
381 }
382 if target.id == ctx.repo.owner_id {
383 set_notice(cx, "owner");
384 return Ok(collaborators_redirect(&title));
385 }
386 let existing = state
387 .store
388 .collaborator_role(&ctx.repo.id, &target.id)
389 .await?;
390 if (role == Role::Admin || existing == Some(Role::Admin))
391 && !privileged(&ctx, &actor, owns_repo_org(state, &ctx, &actor).await?)
392 {
393 return Err(forbidden().into());
394 }
395
396 state
397 .store
398 .set_collaborator(&ctx.repo.id, &target.id, role)
399 .await?;
400 audit::record(
401 &state.store,
402 Some(cx),
403 Actor::User(&actor),
404 if existing.is_some() {
405 AuditAction::CollaboratorRoleChanged
406 } else {
407 AuditAction::CollaboratorAdded
408 },
409 Target {
410 kind: "repo",
411 id: &ctx.repo.id,
412 name: &title,
413 },
414 serde_json::json!({ "user": target.username, "role": role.as_str() }),
415 )
416 .await;
417 tracing::info!(
418 actor_id = %actor.id,
419 repo_id = %ctx.repo.id,
420 repo = %title,
421 target_id = %target.id,
422 role = role.as_str(),
423 updated = existing.is_some(),
424 "collaborator set"
425 );
426 set_notice(
427 cx,
428 if existing.is_some() {
429 "updated"
430 } else {
431 "added"
432 },
433 );
434 Ok(collaborators_redirect(&title))
435}
436
437/// Changes an existing collaborator's role. Only existing collaborators can be changed here.
438#[route(POST "/{owner}/{repo}/settings/collaborators/role")]
439async fn collaborators_role(cx: &Cx, Form(form): Form<RoleForm>) -> Result<SeeOther> {
440 let owner = path_param::<Owner>(cx);
441 let name = path_param::<Repo>(cx);
442 let ctx = load_repo(cx, owner, name).await?;
443 let (actor, _) = require_csrf(cx, &form.csrf).await?;
444 if !ctx.can_admin() {
445 return Err(forbidden().into());
446 }
447 let title = title_of(&ctx);
448 let state = app_context::<AppState>(cx);
449
450 let Ok(role) = form.role.trim().parse::<Role>() else {
451 set_notice(cx, "invalid_role");
452 return Ok(collaborators_redirect(&title));
453 };
454 let Some(existing) = state
455 .store
456 .collaborator_role(&ctx.repo.id, form.user_id.trim())
457 .await?
458 else {
459 set_notice(cx, "not_collaborator");
460 return Ok(collaborators_redirect(&title));
461 };
462 if (role == Role::Admin || existing == Role::Admin)
463 && !privileged(&ctx, &actor, owns_repo_org(state, &ctx, &actor).await?)
464 {
465 return Err(forbidden().into());
466 }
467
468 state
469 .store
470 .set_collaborator(&ctx.repo.id, form.user_id.trim(), role)
471 .await?;
472 audit::record(
473 &state.store,
474 Some(cx),
475 Actor::User(&actor),
476 AuditAction::CollaboratorRoleChanged,
477 Target {
478 kind: "repo",
479 id: &ctx.repo.id,
480 name: &title,
481 },
482 serde_json::json!({ "user": form.user_id.trim(), "role": role.as_str() }),
483 )
484 .await;
485 tracing::info!(
486 actor_id = %actor.id,
487 repo_id = %ctx.repo.id,
488 repo = %title,
489 target_id = %form.user_id,
490 role = role.as_str(),
491 "collaborator role changed"
492 );
493 set_notice(cx, "updated");
494 Ok(collaborators_redirect(&title))
495}
496
497/// Removes a collaborator. Removing another Admin needs the owner or an instance admin. Removing
498/// yourself is allowed and redirects to the repo home page.
499#[route(POST "/{owner}/{repo}/settings/collaborators/remove")]
500async fn collaborators_remove(cx: &Cx, Form(form): Form<RemoveForm>) -> Result<SeeOther> {
501 let owner = path_param::<Owner>(cx);
502 let name = path_param::<Repo>(cx);
503 let ctx = load_repo(cx, owner, name).await?;
504 let (actor, _) = require_csrf(cx, &form.csrf).await?;
505 if !ctx.can_admin() {
506 return Err(forbidden().into());
507 }
508 let title = title_of(&ctx);
509 let state = app_context::<AppState>(cx);
510 let target_id = form.user_id.trim();
511 let self_removal = target_id == actor.id;
512
513 if !self_removal {
514 let existing = state
515 .store
516 .collaborator_role(&ctx.repo.id, target_id)
517 .await?;
518 if existing.is_none() {
519 set_notice(cx, "not_collaborator");
520 return Ok(collaborators_redirect(&title));
521 }
522 if existing == Some(Role::Admin)
523 && !privileged(&ctx, &actor, owns_repo_org(state, &ctx, &actor).await?)
524 {
525 return Err(forbidden().into());
526 }
527 }
528
529 let removed = state
530 .store
531 .remove_collaborator(&ctx.repo.id, target_id)
532 .await?;
533 if removed {
534 audit::record(
535 &state.store,
536 Some(cx),
537 Actor::User(&actor),
538 AuditAction::CollaboratorRemoved,
539 Target {
540 kind: "repo",
541 id: &ctx.repo.id,
542 name: &title,
543 },
544 serde_json::json!({ "user": target_id }),
545 )
546 .await;
547 }
548 tracing::info!(
549 actor_id = %actor.id,
550 repo_id = %ctx.repo.id,
551 repo = %title,
552 target_id = %target_id,
553 removed,
554 "collaborator removed"
555 );
556 if self_removal {
557 return Ok(see_other(format!("/{title}")));
558 }
559 set_notice(
560 cx,
561 if removed {
562 "removed"
563 } else {
564 "not_collaborator"
565 },
566 );
567 Ok(collaborators_redirect(&title))
568}