forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.7 KiB
1//! Shared pieces of the organization pages (`/org/{name}/...`): loading the org for a request and
2//! the signed-in viewer's role in it, the header tabs, the role words, and the one-shot notices
3//! shown after a redirect.
4//!
5//! Visibility rules: a non-member gets 404 on every org page below the profile, so a private org's
6//! membership is not revealed. A member who is not an owner gets 403 on the owner-only actions.
7
8use forge_core::store::orgs::OrgMemberRole;
9use forge_core::{Role, Session, User};
10use topcoat::Result;
11use topcoat::context::{Cx, app_context};
12use topcoat::cookie::{Cookie, Cookies, SameSite, cookies};
13use topcoat::router::error::{forbidden, not_found};
14use topcoat::view::{View, component, view};
15
16use crate::auth::{check_csrf, require_user};
17use crate::i18n::Lang;
18use crate::i18n::orgs::{self as g, K};
19use crate::state::AppState;
20
21/// Cookie that carries a one-shot notice to the next org page. Its value is one of the fixed
22/// words in `NOTICES`, never user text.
23pub const NOTICE_COOKIE: &str = "forge_org_notice";
24
25/// Notice words, the text key each one shows, and whether it is an error.
26pub const NOTICES: &[(&str, K, bool)] = &[
27 ("settings_saved", K::NoticeSettingsSaved, false),
28 ("member_added", K::NoticeMemberAdded, false),
29 ("role_updated", K::NoticeRoleUpdated, false),
30 ("member_removed", K::NoticeMemberRemoved, false),
31 ("team_created", K::NoticeTeamCreated, false),
32 ("team_updated", K::NoticeTeamSaved, false),
33 ("team_deleted", K::NoticeTeamDeleted, false),
34 ("team_member_added", K::NoticeTeamMemberAdded, false),
35 ("team_member_removed", K::NoticeTeamMemberRemoved, false),
36 ("team_repo_added", K::NoticeTeamRepoAdded, false),
37 ("team_repo_removed", K::NoticeTeamRepoRemoved, false),
38 ("empty_username", K::NoticeEmptyUsername, true),
39 ("unknown_user", K::NoticeUnknownUser, true),
40 ("already_member", K::NoticeAlreadyMember, true),
41 ("not_member", K::NoticeNotMember, true),
42 ("last_owner", K::NoticeLastOwner, true),
43 ("invalid_role", K::NoticeInvalidRole, true),
44 ("invalid_name", K::NoticeInvalidName, true),
45 (
46 "team_description_length",
47 K::NoticeTeamDescriptionLength,
48 true,
49 ),
50 ("team_exists", K::NoticeTeamExists, true),
51 ("already_in_team", K::NoticeAlreadyInTeam, true),
52 ("not_in_org", K::NoticeNotInOrg, true),
53 ("not_owned", K::NoticeNotOwned, true),
54 ("already_linked", K::NoticeAlreadyLinked, true),
55 ("has_repos", K::NoticeHasRepos, true),
56 ("confirm_name", K::NoticeConfirmName, true),
57 ("description_length", K::NoticeDescriptionLength, true),
58];
59
60/// Roles a team can carry, in the order the selects show them.
61pub const TEAM_ROLES: [Role; 5] = [
62 Role::Read,
63 Role::Triage,
64 Role::Write,
65 Role::Maintain,
66 Role::Admin,
67];
68
69/// Default repo roles for the org settings select.
70pub const DEFAULT_ROLES: [&str; 3] = ["none", "read", "write"];
71
72/// The org a request is about, and what the viewer may do in it.
73pub struct OrgCtx {
74 pub org: User,
75 pub viewer: (User, Session),
76 /// The viewer's org role; `None` when they are not a member.
77 pub role: Option<OrgMemberRole>,
78}
79
80impl OrgCtx {
81 pub fn is_member(&self) -> bool {
82 self.role.is_some()
83 }
84
85 pub fn is_owner(&self) -> bool {
86 self.role == Some(OrgMemberRole::Owner)
87 }
88
89 /// `/org/{name}{rest}`, for redirects and form actions.
90 pub fn href(&self, rest: &str) -> String {
91 format!("/org/{}{rest}", self.org.username)
92 }
93}
94
95/// Loads the org named `name` for the signed-in viewer. Anonymous viewers go to login. Missing
96/// names and people (anything that is not an org) are 404.
97pub async fn load_org(cx: &Cx, name: &str) -> Result<OrgCtx> {
98 let viewer = require_user(cx).await?;
99 let state = app_context::<AppState>(cx);
100 let Some(org) = state.store.user_by_username(name).await? else {
101 return Err(not_found().into());
102 };
103 if !org.is_org() {
104 return Err(not_found().into());
105 }
106 let role = state.store.org_member_role(&org.id, &viewer.0.id).await?;
107 Ok(OrgCtx { org, viewer, role })
108}
109
110/// 404 unless the viewer is a member of the org.
111pub fn require_member(ctx: &OrgCtx) -> Result<()> {
112 if ctx.is_member() {
113 Ok(())
114 } else {
115 Err(not_found().into())
116 }
117}
118
119/// 404 for non-members, 403 for members who are not owners.
120pub fn require_owner(ctx: &OrgCtx) -> Result<()> {
121 require_member(ctx)?;
122 if ctx.is_owner() {
123 Ok(())
124 } else {
125 Err(forbidden().into())
126 }
127}
128
129/// 403 unless the submitted CSRF token matches the viewer's session.
130pub fn require_form_csrf(ctx: &OrgCtx, submitted: &str) -> Result<()> {
131 if check_csrf(&ctx.viewer.1, submitted) {
132 Ok(())
133 } else {
134 Err(forbidden().into())
135 }
136}
137
138/// Whether `user_id` may create a repo owned by the org `org_id`: an owner always may, a member
139/// only when the org allows members to create repos.
140pub async fn may_create_repo_in(state: &AppState, org_id: &str, user_id: &str) -> Result<bool> {
141 match state.store.org_member_role(org_id, user_id).await? {
142 Some(OrgMemberRole::Owner) => Ok(true),
143 Some(OrgMemberRole::Member) => Ok(state
144 .store
145 .org_settings(org_id)
146 .await?
147 .members_can_create_repos),
148 None => Ok(false),
149 }
150}
151
152/// The stored word for a member role.
153pub fn member_role_word(role: OrgMemberRole) -> &'static str {
154 role.as_str()
155}
156
157/// The member role a form sent, if it is one of the two.
158pub fn parse_member_role(word: &str) -> Option<OrgMemberRole> {
159 OrgMemberRole::parse(word).ok()
160}
161
162/// Sets the notice shown on the next org page view.
163pub fn set_notice(cx: &Cx, word: &'static str) {
164 cookies(cx).add(
165 Cookie::build((NOTICE_COOKIE, word))
166 .path("/")
167 .http_only(true)
168 .same_site(SameSite::Lax)
169 .build(),
170 );
171}
172
173/// Reads and clears the notice cookie. Only the fixed words are shown, in the viewer's language.
174pub fn take_notice(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> {
175 let jar = cookies(cx);
176 let value = jar.get(NOTICE_COOKIE)?.value().to_owned();
177 jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/"));
178 NOTICES
179 .iter()
180 .find(|(word, _, _)| *word == value)
181 .map(|(_, key, is_error)| (g::t(lang, *key), *is_error))
182}
183
184/// The org's name, description and tabs. `active` is `members`, `teams` or `settings`.
185#[component]
186pub async fn org_head(
187 name: String,
188 description: String,
189 active: &'static str,
190 is_owner: bool,
191 lang: Lang,
192) -> Result<impl View> {
193 let members_href = format!("/org/{name}/members");
194 let teams_href = format!("/org/{name}/teams");
195 let settings_href = format!("/org/{name}/settings");
196 let members_tab = active == "members";
197 let teams_tab = active == "teams";
198 let settings_tab = active == "settings";
199 Ok(view! {
200 <section class="page-head org-head">
201 <h1>(name.as_str())</h1>
202 if !description.is_empty() {
203 <p class="muted">(description.as_str())</p>
204 }
205 <nav class="org-tabs" aria-label=(g::t(lang, K::Organization))>
206 if members_tab {
207 <span class="current" aria-current="true">(g::t(lang, K::Members))</span>
208 } else {
209 <a href=(members_href.as_str())>(g::t(lang, K::Members))</a>
210 }
211 if teams_tab {
212 <span class="current" aria-current="true">(g::t(lang, K::Teams))</span>
213 } else {
214 <a href=(teams_href.as_str())>(g::t(lang, K::Teams))</a>
215 }
216 if is_owner {
217 if settings_tab {
218 <span class="current" aria-current="true">(g::t(lang, K::Settings))</span>
219 } else {
220 <a href=(settings_href.as_str())>(g::t(lang, K::Settings))</a>
221 }
222 }
223 </nav>
224 </section>
225 })
226}
227
228/// The notice banner: an error alert, or a status line.
229#[component]
230pub async fn org_notice(notice: Option<(&'static str, bool)>) -> Result<impl View> {
231 Ok(view! {
232 if let Some((text, is_error)) = notice {
233 if is_error {
234 <section class="card error" role="alert">(text)</section>
235 } else {
236 <section class="card notice" role="status">(text)</section>
237 }
238 }
239 })
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn every_notice_word_is_unique() {
248 for (i, (word, _, _)) in NOTICES.iter().enumerate() {
249 assert!(
250 !NOTICES[i + 1..].iter().any(|(other, _, _)| other == word),
251 "{word} is listed twice"
252 );
253 }
254 }
255}