forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
7.9 KiB
1//! Page: organization settings (`/org/{org}/settings`): the public description, the default role
2//! members get on the org's repos, whether members may create repos, and deletion. Owners only:
3//! non-members get 404 and members who are not owners get 403.
4
5use forge_core::AuditAction;
6use forge_core::store::orgs::{DefaultRepoRole, OrgSettings};
7use serde::Deserialize;
8use topcoat::Result;
9use topcoat::context::{Cx, app_context};
10use topcoat::router::content::Form;
11use topcoat::router::error::{SeeOther, see_other};
12use topcoat::router::{page, path_param, route};
13use topcoat::view::{View, component, view};
14
15use crate::audit::{self, Actor, Target};
16use crate::i18n::orgs::{self as g, K};
17use crate::i18n::{self, Lang};
18use crate::pages::org_ctx::{
19 DEFAULT_ROLES, load_org, org_head, org_notice, require_form_csrf, require_owner, set_notice,
20 take_notice,
21};
22use crate::state::AppState;
23use crate::views::shell;
24
25path_param!(org);
26
27const MAX_DESCRIPTION_CHARS: usize = 2000;
28
29#[derive(Deserialize)]
30struct SettingsForm {
31 #[serde(default)]
32 csrf: String,
33 #[serde(default)]
34 description: String,
35 #[serde(default)]
36 default_repo_role: String,
37 /// The checkbox sends `on` when checked and nothing when unchecked.
38 #[serde(default)]
39 members_can_create: String,
40}
41
42#[derive(Deserialize)]
43struct DeleteForm {
44 #[serde(default)]
45 csrf: String,
46 /// The org's name, typed again to confirm the deletion.
47 #[serde(default)]
48 confirm: String,
49}
50
51#[component]
52async fn settings_body(
53 name: String,
54 description: String,
55 base: String,
56 csrf: String,
57 default_role: String,
58 members_can_create: bool,
59 notice: Option<(&'static str, bool)>,
60 lang: Lang,
61) -> Result<impl View> {
62 let save_action = format!("{base}/settings");
63 let delete_action = format!("{base}/settings/delete");
64 let confirm_label = g::type_to_confirm(lang, &name);
65 Ok(view! {
66 org_head(name: name, description: description.clone(), active: "settings", is_owner: true, lang: lang)
67 org_notice(notice: notice)
68 <section class="card">
69 <h2>(g::t(lang, K::Settings))</h2>
70 <form method="post" action=(save_action.as_str()) class="stack">
71 <input type="hidden" name="csrf" value=(csrf.as_str())>
72 <label>
73 <span>(g::t(lang, K::Description))</span>
74 <textarea name="description" rows="3" maxlength="2000">(description.as_str())</textarea>
75 </label>
76 <label>
77 <span>(g::t(lang, K::DefaultRoleLabel))</span>
78 <select name="default_repo_role">
79 for choice in DEFAULT_ROLES.iter() {
80 <option value=(*choice) selected=(*choice == default_role.as_str())>(g::role_name(lang, choice))</option>
81 }
82 </select>
83 </label>
84 <p class="muted small">(g::t(lang, K::DefaultRoleHelp))</p>
85 <label class="inline">
86 <input type="checkbox" name="members_can_create" value="on" checked=(members_can_create)>
87 <span>(g::t(lang, K::MembersCanCreate))</span>
88 </label>
89 <div>
90 <button type="submit" class="primary">(g::t(lang, K::SaveSettings))</button>
91 </div>
92 </form>
93 </section>
94 <section class="card">
95 <h2>(g::t(lang, K::DeleteOrg))</h2>
96 <p class="muted small">(g::t(lang, K::DeleteOrgIntro))</p>
97 <form method="post" action=(delete_action.as_str()) class="stack">
98 <input type="hidden" name="csrf" value=(csrf.as_str())>
99 <label>
100 <span>(confirm_label.as_str())</span>
101 <input type="text" name="confirm" autocomplete="off" spellcheck="false" required=(true)>
102 </label>
103 <div>
104 <button type="submit" class="danger">(g::t(lang, K::DeleteOrg))</button>
105 </div>
106 </form>
107 </section>
108 })
109}
110
111/// Gets the settings page. Owners only.
112#[page("/org/{org}/settings")]
113async fn org_settings_page(cx: &Cx) -> Result<impl View> {
114 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
115 require_owner(&ctx)?;
116 let lang = i18n::lang(cx, Some(&ctx.viewer.0));
117 let state = app_context::<AppState>(cx);
118 let settings = state.store.org_settings(&ctx.org.id).await?;
119 let default_role = settings.default_repo_role.as_str().to_owned();
120 let members_can_create = settings.members_can_create_repos;
121 let notice = take_notice(cx, lang);
122 let title = g::page_title(lang, &ctx.org.username, K::Settings);
123 let name = ctx.org.username.clone();
124 let description = ctx.org.about.clone();
125 let base = ctx.href("");
126 let csrf = ctx.viewer.1.csrf_token.clone();
127 Ok(view! { cx =>
128 shell(title: title.as_str(), viewer: Some(&ctx.viewer),
129 settings_body(name: name, description: description, base: base, csrf: csrf,
130 default_role: default_role, members_can_create: members_can_create, notice: notice, lang: lang))
131 })
132}
133
134/// Saves the description and the org settings. Owners only.
135#[route(POST "/org/{org}/settings")]
136async fn org_settings_save(cx: &Cx, Form(form): Form<SettingsForm>) -> Result<SeeOther> {
137 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
138 require_form_csrf(&ctx, &form.csrf)?;
139 require_owner(&ctx)?;
140 let state = app_context::<AppState>(cx);
141 let back = ctx.href("/settings");
142
143 let description = form.description.trim();
144 if description.chars().count() > MAX_DESCRIPTION_CHARS {
145 set_notice(cx, "description_length");
146 return Ok(see_other(back));
147 }
148 let Ok(default_repo_role) = DefaultRepoRole::parse(form.default_repo_role.trim()) else {
149 set_notice(cx, "invalid_role");
150 return Ok(see_other(back));
151 };
152 let members_can_create_repos = !form.members_can_create.is_empty();
153
154 state
155 .store
156 .set_org_settings(
157 &ctx.org.id,
158 OrgSettings {
159 default_repo_role,
160 members_can_create_repos,
161 },
162 )
163 .await?;
164 state
165 .store
166 .set_profile(&ctx.org.id, description, &ctx.org.timezone)
167 .await?;
168 audit::record(
169 &state.store,
170 Some(cx),
171 Actor::User(&ctx.viewer.0),
172 AuditAction::OrgSettingsUpdated,
173 Target {
174 kind: "org",
175 id: &ctx.org.id,
176 name: &ctx.org.username,
177 },
178 serde_json::json!({
179 "default_repo_role": default_repo_role.as_str(),
180 "members_can_create_repos": members_can_create_repos,
181 }),
182 )
183 .await;
184 set_notice(cx, "settings_saved");
185 Ok(see_other(back))
186}
187
188/// Deletes the org. Owners only, and the name must be typed again. Refused while the org owns
189/// repositories; then the settings page says so.
190#[route(POST "/org/{org}/settings/delete")]
191async fn org_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<SeeOther> {
192 let ctx = load_org(cx, path_param::<Org>(cx)).await?;
193 require_form_csrf(&ctx, &form.csrf)?;
194 require_owner(&ctx)?;
195 let state = app_context::<AppState>(cx);
196 let back = ctx.href("/settings");
197
198 if form.confirm.trim() != ctx.org.username {
199 set_notice(cx, "confirm_name");
200 return Ok(see_other(back));
201 }
202 match state.store.delete_org(&ctx.org.id).await {
203 Ok(()) => {}
204 Err(forge_core::Error::Conflict(_)) => {
205 set_notice(cx, "has_repos");
206 return Ok(see_other(back));
207 }
208 Err(e) => return Err(e.into()),
209 }
210 audit::record(
211 &state.store,
212 Some(cx),
213 Actor::User(&ctx.viewer.0),
214 AuditAction::OrgDeleted,
215 Target {
216 kind: "org",
217 id: &ctx.org.id,
218 name: &ctx.org.username,
219 },
220 serde_json::Value::Null,
221 )
222 .await;
223 Ok(see_other("/"))
224}