forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
27.5 KiB
1//! Pages: organization teams (`/org/{org}/teams`, `/org/{org}/teams/{team}`). Members see the teams,
2//! their members and the repositories they cover. Owners create, edit and delete teams, add people
3//! to them (an organization member only), and link the org's own repositories.
4//!
5//! A team's role is what its members get on the repos it covers: every repo the org owns when
6//! `all_repos` is set, otherwise only the repos linked to it (see `Store::org_repo_role`).
7
8use forge_core::authz::{Action, authorize};
9use forge_core::{AuditAction, Role};
10use serde::Deserialize;
11use topcoat::Result;
12use topcoat::context::{Cx, app_context};
13use topcoat::router::content::Form;
14use topcoat::router::error::{SeeOther, not_found, 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 OrgCtx, TEAM_ROLES, load_org, org_head, org_notice, require_form_csrf, require_member,
23 require_owner, set_notice, take_notice,
24};
25use crate::state::AppState;
26use crate::views::{avatar_url, display_name, repo_href, shell};
27
28path_param!(org);
29path_param!(team);
30
31const MAX_TEAM_DESCRIPTION_CHARS: usize = 350;
32
33#[derive(Deserialize)]
34struct CsrfForm {
35 #[serde(default)]
36 csrf: String,
37}
38
39#[derive(Deserialize)]
40struct TeamForm {
41 #[serde(default)]
42 csrf: String,
43 #[serde(default)]
44 name: String,
45 #[serde(default)]
46 description: String,
47 #[serde(default)]
48 role: String,
49 /// The checkbox sends `on` when checked and nothing when unchecked.
50 #[serde(default)]
51 all_repos: String,
52}
53
54#[derive(Deserialize)]
55struct TeamMemberForm {
56 #[serde(default)]
57 csrf: String,
58 #[serde(default)]
59 username: String,
60}
61
62#[derive(Deserialize)]
63struct TeamUserForm {
64 #[serde(default)]
65 csrf: String,
66 #[serde(default)]
67 user_id: String,
68}
69
70#[derive(Deserialize)]
71struct TeamRepoForm {
72 #[serde(default)]
73 csrf: String,
74 #[serde(default)]
75 repo_id: String,
76}
77
78/// One row of the teams list.
79struct TeamRow {
80 href: String,
81 name: String,
82 description: String,
83 role: &'static str,
84 all_repos: bool,
85}
86
87/// A team member shown on the team page.
88struct TeamMemberRow {
89 user_id: String,
90 username: String,
91 label: String,
92 avatar: String,
93}
94
95/// A repo linked to the team, shown on the team page.
96struct TeamRepoRow {
97 repo_id: String,
98 href: String,
99 title: String,
100}
101
102/// A repo the org owns that is not linked to the team yet: the select's options.
103struct RepoChoice {
104 id: String,
105 title: String,
106}
107
108/// Everything the team page shows besides the notice.
109struct TeamPage {
110 org_name: String,
111 org_description: String,
112 base: String,
113 owner: bool,
114 csrf: String,
115 team_name: String,
116 team_description: String,
117 team_role: &'static str,
118 team_all_repos: bool,
119 members: Vec<TeamMemberRow>,
120 repos: Vec<TeamRepoRow>,
121 choices: Vec<RepoChoice>,
122}
123
124/// The role a form sent, if it is one a team may carry.
125fn parse_team_role(word: &str) -> Option<Role> {
126 TEAM_ROLES
127 .iter()
128 .copied()
129 .find(|role| role.as_str() == word.trim())
130}
131
132/// The notice word for a store error from creating or renaming a team, or `None` for an error
133/// that is not the user's to fix.
134fn team_error_notice(err: &forge_core::Error) -> Option<&'static str> {
135 match err {
136 forge_core::Error::Conflict(_) => Some("team_exists"),
137 forge_core::Error::InvalidName(_) | forge_core::Error::Invalid(_) => Some("invalid_name"),
138 _ => None,
139 }
140}
141
142/// The team named in the path, if it belongs to this org. Anything else is 404.
143async fn team_of(
144 state: &AppState,
145 ctx: &OrgCtx,
146 team_id: &str,
147) -> Result<forge_core::store::orgs::Team> {
148 match state.store.team_by_id(team_id).await? {
149 Some(team) if team.org_id == ctx.org.id => Ok(team),
150 _ => Err(not_found().into()),
151 }
152}
153
154#[component]
155async fn teams_body(
156 name: String,
157 description: String,
158 base: String,
159 owner: bool,
160 csrf: String,
161 rows: Vec<TeamRow>,
162 notice: Option<(&'static str, bool)>,
163 lang: Lang,
164) -> Result<impl View> {
165 let create_action = format!("{base}/teams/new");
166 Ok(view! {
167 org_head(name: name, description: description, active: "teams", is_owner: owner, lang: lang)
168 org_notice(notice: notice)
169 <section class="card">
170 <h2>(g::t(lang, K::Teams))</h2>
171 <p class="muted small">(g::t(lang, K::TeamsIntro))</p>
172 if rows.is_empty() {
173 <p class="muted">(g::t(lang, K::NoTeamsYet))</p>
174 } else {
175 <ul class="item-list">
176 for row in rows.iter() {
177 <li class="item">
178 <div class="item-main">
179 <a href=(row.href.as_str())><strong>(row.name.as_str())</strong></a>
180 <span class="muted small">(g::coverage_text(lang, row.role, row.all_repos))</span>
181 if !row.description.is_empty() {
182 <span class="muted small">(row.description.as_str())</span>
183 }
184 </div>
185 </li>
186 }
187 </ul>
188 }
189 </section>
190 if owner {
191 <section class="card">
192 <h3>(g::t(lang, K::NewTeam))</h3>
193 <form method="post" action=(create_action.as_str()) class="stack">
194 <input type="hidden" name="csrf" value=(csrf.as_str())>
195 <label>
196 <span>(g::t(lang, K::Name))</span>
197 <input type="text" name="name" autocomplete="off" required=(true) maxlength="64">
198 </label>
199 <label>
200 <span>(g::t(lang, K::DescriptionOptional))</span>
201 <textarea name="description" rows="2" maxlength="350"></textarea>
202 </label>
203 <label>
204 <span>(g::t(lang, K::Role))</span>
205 <select name="role">
206 for role in TEAM_ROLES.iter() {
207 <option value=(role.as_str()) selected=(*role == Role::Read)>(g::role_name(lang, role.as_str()))</option>
208 }
209 </select>
210 </label>
211 <label class="inline">
212 <input type="checkbox" name="all_repos" value="on">
213 <span>(g::t(lang, K::CoversAllRepos))</span>
214 </label>
215 <div>
216 <button type="submit" class="primary">(g::t(lang, K::CreateTeam))</button>
217 </div>
218 </form>
219 </section>
220 }
221 })
222}
223
224/// Gets the teams list. Members only.
225#[page("/org/{org}/teams")]
226async fn org_teams_page(cx: &Cx) -> Result<impl View> {
227 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
228 require_member(&ctx)?;
229 let lang = i18n::lang(cx, Some(&ctx.viewer.0));
230 let state = app_context::<AppState>(cx);
231 let teams = state.store.teams_for_org(&ctx.org.id).await?;
232 let rows: Vec<TeamRow> = teams
233 .into_iter()
234 .map(|team| TeamRow {
235 href: ctx.href(&format!("/teams/{}", team.id)),
236 role: team.role.as_str(),
237 all_repos: team.all_repos,
238 name: team.name,
239 description: team.description,
240 })
241 .collect();
242 let owner = ctx.is_owner();
243 let notice = take_notice(cx, lang);
244 let title = g::page_title(lang, &ctx.org.username, K::Teams);
245 let name = ctx.org.username.clone();
246 let description = ctx.org.about.clone();
247 let base = ctx.href("");
248 let csrf = ctx.viewer.1.csrf_token.clone();
249 Ok(view! { cx =>
250 shell(title: title.as_str(), viewer: Some(&ctx.viewer),
251 teams_body(name: name, description: description, base: base, owner: owner,
252 csrf: csrf, rows: rows, notice: notice, lang: lang))
253 })
254}
255
256#[component]
257async fn team_body(
258 data: TeamPage,
259 notice: Option<(&'static str, bool)>,
260 lang: Lang,
261) -> Result<impl View> {
262 let base = data.base.clone();
263 let update_action = format!("{base}/update");
264 let delete_action = format!("{base}/delete");
265 let add_member_action = format!("{base}/members/add");
266 let remove_member_action = format!("{base}/members/remove");
267 let add_repo_action = format!("{base}/repos/add");
268 let remove_repo_action = format!("{base}/repos/remove");
269 let csrf = data.csrf.clone();
270 let owner = data.owner;
271 let has_choices = !data.choices.is_empty();
272 let has_description = !data.team_description.is_empty();
273 Ok(view! {
274 org_head(name: data.org_name.clone(), description: data.org_description.clone(), active: "teams", is_owner: owner, lang: lang)
275 org_notice(notice: notice)
276 <section class="card">
277 <h2>(data.team_name.as_str())</h2>
278 if has_description {
279 <p class="muted">(data.team_description.as_str())</p>
280 }
281 <p class="muted small">(g::coverage_text(lang, data.team_role, data.team_all_repos))</p>
282 if owner {
283 <form method="post" action=(update_action.as_str()) class="stack">
284 <input type="hidden" name="csrf" value=(csrf.as_str())>
285 <label>
286 <span>(g::t(lang, K::Name))</span>
287 <input type="text" name="name" value=(data.team_name.as_str()) autocomplete="off" required=(true) maxlength="64">
288 </label>
289 <label>
290 <span>(g::t(lang, K::DescriptionOptional))</span>
291 <textarea name="description" rows="2" maxlength="350">(data.team_description.as_str())</textarea>
292 </label>
293 <label>
294 <span>(g::t(lang, K::Role))</span>
295 <select name="role">
296 for role in TEAM_ROLES.iter() {
297 <option value=(role.as_str()) selected=(role.as_str() == data.team_role)>(g::role_name(lang, role.as_str()))</option>
298 }
299 </select>
300 </label>
301 <label class="inline">
302 <input type="checkbox" name="all_repos" value="on" checked=(data.team_all_repos)>
303 <span>(g::t(lang, K::CoversAllRepos))</span>
304 </label>
305 <div>
306 <button type="submit" class="primary">(g::t(lang, K::SaveTeam))</button>
307 </div>
308 </form>
309 <form method="post" action=(delete_action.as_str()) class="inline-form">
310 <input type="hidden" name="csrf" value=(csrf.as_str())>
311 <button type="submit" class="danger">(g::t(lang, K::DeleteTeam))</button>
312 </form>
313 }
314 </section>
315 <section class="card">
316 <h3>(g::t(lang, K::Members))</h3>
317 if data.members.is_empty() {
318 <p class="muted">(g::t(lang, K::NoTeamMembers))</p>
319 } else {
320 <ul class="item-list">
321 for member in data.members.iter() {
322 <li class="item">
323 <div class="item-main">
324 <a class="user-link" href=(format!("/{}", member.username.as_str()))>
325 <img class="avatar avatar-sm" src=(member.avatar.as_str()) alt="" width="24" height="24">
326 <span>(member.label.as_str())</span>
327 </a>
328 </div>
329 if owner {
330 <form method="post" action=(remove_member_action.as_str()) class="remove-form">
331 <input type="hidden" name="csrf" value=(csrf.as_str())>
332 <input type="hidden" name="user_id" value=(member.user_id.as_str())>
333 <button type="submit">(g::t(lang, K::Remove))</button>
334 </form>
335 }
336 </li>
337 }
338 </ul>
339 }
340 if owner {
341 <form method="post" action=(add_member_action.as_str()) class="inline-form">
342 <input type="hidden" name="csrf" value=(csrf.as_str())>
343 <input type="text" name="username" placeholder=(g::t(lang, K::Username)) aria-label=(g::t(lang, K::Username)) autocomplete="off" spellcheck="false" required=(true)>
344 <button type="submit">(g::t(lang, K::AddToTeam))</button>
345 </form>
346 }
347 </section>
348 <section class="card">
349 <h3>(g::t(lang, K::Repositories))</h3>
350 if data.repos.is_empty() {
351 <p class="muted">(g::t(lang, K::NoTeamRepos))</p>
352 } else {
353 <ul class="item-list">
354 for repo in data.repos.iter() {
355 <li class="item">
356 <div class="item-main">
357 <a href=(repo.href.as_str())>(repo.title.as_str())</a>
358 </div>
359 if owner {
360 <form method="post" action=(remove_repo_action.as_str()) class="remove-form">
361 <input type="hidden" name="csrf" value=(csrf.as_str())>
362 <input type="hidden" name="repo_id" value=(repo.repo_id.as_str())>
363 <button type="submit">(g::t(lang, K::Unlink))</button>
364 </form>
365 }
366 </li>
367 }
368 </ul>
369 }
370 if owner && has_choices {
371 <form method="post" action=(add_repo_action.as_str()) class="inline-form">
372 <input type="hidden" name="csrf" value=(csrf.as_str())>
373 <select name="repo_id" aria-label=(g::t(lang, K::Repository))>
374 for choice in data.choices.iter() {
375 <option value=(choice.id.as_str())>(choice.title.as_str())</option>
376 }
377 </select>
378 <button type="submit">(g::t(lang, K::LinkRepository))</button>
379 </form>
380 }
381 </section>
382 })
383}
384
385/// Gets one team's page. Members only; a team of another org is 404.
386#[page("/org/{org}/teams/{team}")]
387async fn org_team_page(cx: &Cx) -> Result<impl View> {
388 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
389 require_member(&ctx)?;
390 let lang = i18n::lang(cx, Some(&ctx.viewer.0));
391 let state = app_context::<AppState>(cx);
392 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
393 let members: Vec<TeamMemberRow> = state
394 .store
395 .team_members(&team.id)
396 .await?
397 .into_iter()
398 .map(|user| TeamMemberRow {
399 user_id: user.id.clone(),
400 avatar: avatar_url(&user.username, user.avatar_hash.as_deref()),
401 label: display_name(lang, &user.username),
402 username: user.username,
403 })
404 .collect();
405 // Only the linked repos the viewer may read are named, so a private repo's name stays hidden.
406 let linked = state.store.team_repos(&team.id).await?;
407 let mut repos: Vec<TeamRepoRow> = Vec::new();
408 for repo in linked.iter() {
409 let role = state
410 .store
411 .collaborator_role(&repo.id, &ctx.viewer.0.id)
412 .await?;
413 let org_role = state
414 .store
415 .org_repo_role(&repo.owner_id, &ctx.viewer.0.id, &repo.id)
416 .await?;
417 if authorize(Some(&ctx.viewer.0), repo, role, org_role, Action::Read).is_ok() {
418 repos.push(TeamRepoRow {
419 repo_id: repo.id.clone(),
420 href: repo_href(&repo.owner_name, &repo.name, "", ""),
421 title: format!("{}/{}", repo.owner_name, repo.name),
422 });
423 }
424 }
425 let choices: Vec<RepoChoice> = state
426 .store
427 .list_repos_by_owner(&ctx.org.id)
428 .await?
429 .into_iter()
430 .filter(|repo| !linked.iter().any(|l| l.id == repo.id))
431 .map(|repo| RepoChoice {
432 title: format!("{}/{}", repo.owner_name, repo.name),
433 id: repo.id,
434 })
435 .collect();
436 let title = format!("{} \u{b7} {}", ctx.org.username, team.name);
437 let data = TeamPage {
438 org_name: ctx.org.username.clone(),
439 org_description: ctx.org.about.clone(),
440 base: ctx.href(&format!("/teams/{}", team.id)),
441 owner: ctx.is_owner(),
442 csrf: ctx.viewer.1.csrf_token.clone(),
443 team_name: team.name.clone(),
444 team_description: team.description.clone(),
445 team_role: team.role.as_str(),
446 team_all_repos: team.all_repos,
447 members,
448 repos,
449 choices,
450 };
451 let notice = take_notice(cx, lang);
452 Ok(view! { cx =>
453 shell(title: title.as_str(), viewer: Some(&ctx.viewer),
454 team_body(data: data, notice: notice, lang: lang))
455 })
456}
457
458/// Creates a team. Owners only.
459#[route(POST "/org/{org}/teams/new")]
460async fn org_team_create(cx: &Cx, Form(form): Form<TeamForm>) -> Result<SeeOther> {
461 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
462 require_form_csrf(&ctx, &form.csrf)?;
463 require_owner(&ctx)?;
464 let state = app_context::<AppState>(cx);
465 let back = ctx.href("/teams");
466
467 let Some(role) = parse_team_role(&form.role) else {
468 set_notice(cx, "invalid_role");
469 return Ok(see_other(back));
470 };
471 let description = form.description.trim();
472 if description.chars().count() > MAX_TEAM_DESCRIPTION_CHARS {
473 set_notice(cx, "team_description_length");
474 return Ok(see_other(back));
475 }
476 let all_repos = !form.all_repos.is_empty();
477 let team = match state
478 .store
479 .create_team(&ctx.org.id, form.name.trim(), description, role, all_repos)
480 .await
481 {
482 Ok(team) => team,
483 Err(e) => match team_error_notice(&e) {
484 Some(word) => {
485 set_notice(cx, word);
486 return Ok(see_other(back));
487 }
488 None => return Err(e.into()),
489 },
490 };
491 audit::record(
492 &state.store,
493 Some(cx),
494 Actor::User(&ctx.viewer.0),
495 AuditAction::TeamCreated,
496 Target {
497 kind: "team",
498 id: &team.id,
499 name: &team.name,
500 },
501 serde_json::json!({
502 "org": ctx.org.username,
503 "role": role.as_str(),
504 "all_repos": all_repos,
505 }),
506 )
507 .await;
508 set_notice(cx, "team_created");
509 Ok(see_other(ctx.href(&format!("/teams/{}", team.id))))
510}
511
512/// Renames a team and changes its description, role and coverage. Owners only.
513#[route(POST "/org/{org}/teams/{team}/update")]
514async fn org_team_update(cx: &Cx, Form(form): Form<TeamForm>) -> Result<SeeOther> {
515 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
516 require_form_csrf(&ctx, &form.csrf)?;
517 require_owner(&ctx)?;
518 let state = app_context::<AppState>(cx);
519 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
520 let back = ctx.href(&format!("/teams/{}", team.id));
521
522 let Some(role) = parse_team_role(&form.role) else {
523 set_notice(cx, "invalid_role");
524 return Ok(see_other(back));
525 };
526 let description = form.description.trim();
527 if description.chars().count() > MAX_TEAM_DESCRIPTION_CHARS {
528 set_notice(cx, "team_description_length");
529 return Ok(see_other(back));
530 }
531 let all_repos = !form.all_repos.is_empty();
532 match state
533 .store
534 .update_team(&team.id, form.name.trim(), description, role, all_repos)
535 .await
536 {
537 Ok(()) => {}
538 Err(e) => match team_error_notice(&e) {
539 Some(word) => {
540 set_notice(cx, word);
541 return Ok(see_other(back));
542 }
543 None => return Err(e.into()),
544 },
545 }
546 let name = form.name.trim().to_owned();
547 audit::record(
548 &state.store,
549 Some(cx),
550 Actor::User(&ctx.viewer.0),
551 AuditAction::TeamUpdated,
552 Target {
553 kind: "team",
554 id: &team.id,
555 name: &name,
556 },
557 serde_json::json!({
558 "role": role.as_str(),
559 "all_repos": all_repos,
560 }),
561 )
562 .await;
563 set_notice(cx, "team_updated");
564 Ok(see_other(back))
565}
566
567/// Deletes a team. Owners only.
568#[route(POST "/org/{org}/teams/{team}/delete")]
569async fn org_team_delete(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
570 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
571 require_form_csrf(&ctx, &form.csrf)?;
572 require_owner(&ctx)?;
573 let state = app_context::<AppState>(cx);
574 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
575 let name = team.name.clone();
576 state.store.delete_team(&team.id).await?;
577 audit::record(
578 &state.store,
579 Some(cx),
580 Actor::User(&ctx.viewer.0),
581 AuditAction::TeamDeleted,
582 Target {
583 kind: "team",
584 id: &team.id,
585 name: &name,
586 },
587 serde_json::Value::Null,
588 )
589 .await;
590 set_notice(cx, "team_deleted");
591 Ok(see_other(ctx.href("/teams")))
592}
593
594/// Adds an organization member to a team. Owners only.
595#[route(POST "/org/{org}/teams/{team}/members/add")]
596async fn org_team_member_add(cx: &Cx, Form(form): Form<TeamMemberForm>) -> Result<SeeOther> {
597 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
598 require_form_csrf(&ctx, &form.csrf)?;
599 require_owner(&ctx)?;
600 let state = app_context::<AppState>(cx);
601 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
602 let back = ctx.href(&format!("/teams/{}", team.id));
603
604 let username = form.username.trim();
605 if username.is_empty() {
606 set_notice(cx, "empty_username");
607 return Ok(see_other(back));
608 }
609 let Some(target) = state.store.user_by_username(username).await? else {
610 set_notice(cx, "unknown_user");
611 return Ok(see_other(back));
612 };
613 if target.is_org() {
614 set_notice(cx, "unknown_user");
615 return Ok(see_other(back));
616 }
617 match state.store.add_team_member(&team.id, &target.id).await {
618 Ok(()) => {}
619 Err(forge_core::Error::Invalid(_)) => {
620 set_notice(cx, "not_in_org");
621 return Ok(see_other(back));
622 }
623 Err(forge_core::Error::Conflict(_)) => {
624 set_notice(cx, "already_in_team");
625 return Ok(see_other(back));
626 }
627 Err(e) => return Err(e.into()),
628 }
629 audit::record(
630 &state.store,
631 Some(cx),
632 Actor::User(&ctx.viewer.0),
633 AuditAction::TeamMemberAdded,
634 Target {
635 kind: "team",
636 id: &team.id,
637 name: &team.name,
638 },
639 serde_json::json!({ "user": target.username }),
640 )
641 .await;
642 set_notice(cx, "team_member_added");
643 Ok(see_other(back))
644}
645
646/// Removes a person from a team. Owners only.
647#[route(POST "/org/{org}/teams/{team}/members/remove")]
648async fn org_team_member_remove(cx: &Cx, Form(form): Form<TeamUserForm>) -> Result<SeeOther> {
649 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
650 require_form_csrf(&ctx, &form.csrf)?;
651 require_owner(&ctx)?;
652 let state = app_context::<AppState>(cx);
653 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
654 let back = ctx.href(&format!("/teams/{}", team.id));
655
656 let user_id = form.user_id.trim();
657 if state.store.remove_team_member(&team.id, user_id).await? {
658 audit::record(
659 &state.store,
660 Some(cx),
661 Actor::User(&ctx.viewer.0),
662 AuditAction::TeamMemberRemoved,
663 Target {
664 kind: "team",
665 id: &team.id,
666 name: &team.name,
667 },
668 serde_json::json!({ "user": user_id }),
669 )
670 .await;
671 set_notice(cx, "team_member_removed");
672 }
673 Ok(see_other(back))
674}
675
676/// Links one of the org's repos to a team. Owners only.
677#[route(POST "/org/{org}/teams/{team}/repos/add")]
678async fn org_team_repo_add(cx: &Cx, Form(form): Form<TeamRepoForm>) -> Result<SeeOther> {
679 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
680 require_form_csrf(&ctx, &form.csrf)?;
681 require_owner(&ctx)?;
682 let state = app_context::<AppState>(cx);
683 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
684 let back = ctx.href(&format!("/teams/{}", team.id));
685
686 let repo_id = form.repo_id.trim();
687 match state.store.add_team_repo(&team.id, repo_id).await {
688 Ok(()) => {}
689 Err(forge_core::Error::Invalid(_)) => {
690 set_notice(cx, "not_owned");
691 return Ok(see_other(back));
692 }
693 Err(forge_core::Error::Conflict(_)) => {
694 set_notice(cx, "already_linked");
695 return Ok(see_other(back));
696 }
697 Err(e) => return Err(e.into()),
698 }
699 audit::record(
700 &state.store,
701 Some(cx),
702 Actor::User(&ctx.viewer.0),
703 AuditAction::TeamRepoAdded,
704 Target {
705 kind: "team",
706 id: &team.id,
707 name: &team.name,
708 },
709 serde_json::json!({ "repo_id": repo_id }),
710 )
711 .await;
712 set_notice(cx, "team_repo_added");
713 Ok(see_other(back))
714}
715
716/// Unlinks a repo from a team. Owners only.
717#[route(POST "/org/{org}/teams/{team}/repos/remove")]
718async fn org_team_repo_remove(cx: &Cx, Form(form): Form<TeamRepoForm>) -> Result<SeeOther> {
719 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
720 require_form_csrf(&ctx, &form.csrf)?;
721 require_owner(&ctx)?;
722 let state = app_context::<AppState>(cx);
723 let team = team_of(state, &ctx, path_param::<Team>(cx)).await?;
724 let back = ctx.href(&format!("/teams/{}", team.id));
725
726 let repo_id = form.repo_id.trim();
727 if state.store.remove_team_repo(&team.id, repo_id).await? {
728 audit::record(
729 &state.store,
730 Some(cx),
731 Actor::User(&ctx.viewer.0),
732 AuditAction::TeamRepoRemoved,
733 Target {
734 kind: "team",
735 id: &team.id,
736 name: &team.name,
737 },
738 serde_json::json!({ "repo_id": repo_id }),
739 )
740 .await;
741 set_notice(cx, "team_repo_removed");
742 }
743 Ok(see_other(back))
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn team_roles_parse_only_the_five_team_roles() {
752 assert_eq!(parse_team_role("read"), Some(Role::Read));
753 assert_eq!(parse_team_role(" admin "), Some(Role::Admin));
754 assert_eq!(parse_team_role("owner"), None);
755 assert_eq!(parse_team_role(""), None);
756 }
757
758 #[test]
759 fn coverage_reads_in_words() {
760 assert_eq!(
761 g::coverage_text(Lang::En, "write", true),
762 "write on all repositories"
763 );
764 assert_eq!(
765 g::coverage_text(Lang::En, "read", false),
766 "read on linked repositories"
767 );
768 }
769
770 #[test]
771 fn duplicate_and_bad_names_get_their_own_notices() {
772 let dup = forge_core::Error::Conflict("taken".into());
773 let bad = forge_core::Error::InvalidName("bad".into());
774 let other = forge_core::Error::NotFound;
775 assert_eq!(team_error_notice(&dup), Some("team_exists"));
776 assert_eq!(team_error_notice(&bad), Some("invalid_name"));
777 assert_eq!(team_error_notice(&other), None);
778 }
779}