@jpgilldev / steid

steid/src/infrastructure/web/repo_settings.rs
12.6 KBRaw
1//! Repository settings — `/{handle}/repos/{name}/settings`.
2//!
3//! Owner-only: change the description, change visibility, delete.
4//!
5//! `settings` sits in the verb position after `{name}`, where nothing user-controlled
6//! ever appears, so unlike `new` it needs no reservation in
7//! [`RepoName`](crate::domain::RepoName).
8//!
9//! **A non-owner gets a 404, not a 403.** That is the rule
10//! [`view_repo`](crate::application::view_repo) already sets for repositories: a 403
11//! would confirm that a private repository by that name exists, and there is no reason
12//! for the settings page to be more talkative than the repository page it belongs to.
13
14use serde::Deserialize;
15use topcoat::{
16 Result,
17 context::Cx,
18 router::{StatusCode, content::Form, error::not_found, page, query_params},
19 view::{attributes, component, view},
20};
21
22use crate::{
23 application::{
24 Error, RepoView,
25 repo::{RepoEdit, delete_repo, update_repo},
26 },
27 components::{
28 button::{ButtonVariant, button},
29 flash::{FlashKind, flash},
30 input::input,
31 label::label,
32 select::select,
33 textarea::textarea,
34 },
35 domain::{DomainError, RepoName, Repository, Visibility},
36};
37
38use super::{
39 context::{current_actor, location, memberships, orgs, repos, server_error, storage},
40 repo::repo_for,
41};
42
43#[derive(Debug, Deserialize)]
44struct SettingsForm {
45 description: String,
46 visibility: String,
47 /// A checkbox: present when ticked, absent entirely when not. `Option` is the shape
48 /// the browser actually sends, so it is the shape parsed.
49 pinned: Option<String>,
50}
51
52#[derive(Debug, Deserialize)]
53struct DeleteForm {
54 confirm: String,
55}
56
57/// Set after a successful save so the confirmation survives the redirect.
58///
59/// Post-redirect-get: reloading after a save must not resubmit it.
60#[query_params(error = bad_request)]
61struct Saved {
62 saved: Option<String>,
63}
64
65/// Blank input means "clear this field", which the domain treats as unset.
66fn optional(value: &str) -> Option<String> {
67 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
68}
69
70/// Resolves the repository in the path, or 404 for anyone who does not own it.
71///
72/// [`repo_for`] already 404s for a repository the viewer may not see; this adds the
73/// stronger half. The use cases decide this too — the guard here only keeps a page from
74/// rendering for someone whose submission would be refused anyway.
75async fn owned_repo(cx: &Cx) -> Result<RepoView> {
76 let repo = repo_for(cx).await?;
77
78 if !repo.viewer_is_owner {
79 return Err(not_found().into());
80 }
81
82 Ok(repo)
83}
84
85#[page("/{handle}/repos/{name}/settings")]
86async fn repo_settings_page(cx: &Cx) -> Result {
87 let repo = owned_repo(cx).await?;
88 let saved = query_params::<Saved>(cx)?.saved.is_some();
89
90 view! {
91 settings_view(
92 handle: repo.handle.as_str(),
93 name: repo.name.as_str(),
94 fields: Fields {
95 description: repo.description.as_deref().unwrap_or(""),
96 visibility: repo.visibility,
97 pinned: repo.pinned,
98 },
99 saved: saved,
100 error: "",
101 )
102 }
103}
104
105/// Saves the description and visibility.
106///
107/// Success replies 303 so a reload cannot resubmit — see
108/// [`location`](super::context::location) for why it is spelled this way rather than
109/// with `redirect()`, which is a 307 and would re-POST this form to itself.
110///
111/// Failure re-renders with the reason and **what was typed**, rather than bouncing back
112/// to the stored values and hiding what went wrong.
113#[page(POST "/{handle}/repos/{name}/settings")]
114async fn save(cx: &Cx, Form(submitted): Form<SettingsForm>) -> Result {
115 let repo = owned_repo(cx).await?;
116
117 // An unparseable value is a tampered form, not something to default: defaulting
118 // here could publish a repository the owner asked to keep private.
119 let visibility = submitted
120 .visibility
121 .parse::<Visibility>()
122 .map_err(|_| topcoat::router::error::bad_request("unknown visibility"))?;
123
124 let outcome = update_repo(
125 &current_actor(cx).await?,
126 &repo.handle,
127 &repo.name,
128 &RepoEdit {
129 description: optional(&submitted.description),
130 visibility,
131 pinned: submitted.pinned.is_some(),
132 },
133 &orgs(cx),
134 &memberships(cx),
135 &repos(cx),
136 )
137 .await;
138
139 let message = match outcome {
140 Ok(_) => {
141 return view! {
142 (StatusCode::SEE_OTHER)
143 (location(&format!(
144 "/{}/repos/{}/settings?saved",
145 repo.handle, repo.name
146 ))?)
147 };
148 }
149 // The visitor's to fix, so it is shown.
150 Err(Error::Domain(DomainError::Validation { field, reason })) => {
151 format!("That {field} is no good: {reason}.")
152 }
153 // Both mean "not yours" here, and both look like a page that is not there.
154 Err(Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. })) => {
155 return Err(not_found().into());
156 }
157 // Ours, so it is logged and answered generically.
158 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
159 };
160
161 view! {
162 settings_view(
163 handle: repo.handle.as_str(),
164 name: repo.name.as_str(),
165 fields: Fields {
166 description: submitted.description.as_str(),
167 visibility,
168 pinned: submitted.pinned.is_some(),
169 },
170 saved: false,
171 error: message.as_str(),
172 )
173 }
174}
175
176/// Deletes the repository, then sends the owner back to their profile.
177///
178/// The repository page no longer exists, so there is nowhere else to go.
179///
180/// **Guarded by typing the name.** Deletion takes the git history with it and there is
181/// no undo; a single button is too easy to hit by accident for something unrecoverable.
182/// The typed value goes through [`RepoName::new`], so it is compared the same way the
183/// name was normalised on the way in — `MyRepo` confirms `myrepo`, and stray whitespace
184/// is not a reason to refuse.
185#[page(POST "/{handle}/repos/{name}/settings/delete")]
186async fn delete(cx: &Cx, Form(submitted): Form<DeleteForm>) -> Result {
187 let repo = owned_repo(cx).await?;
188
189 let confirmed = RepoName::new(&submitted.confirm).is_ok_and(|typed| typed == repo.name);
190
191 if !confirmed {
192 let message = format!(
193 "Type {} exactly to confirm. Nothing was deleted.",
194 repo.name
195 );
196
197 return view! {
198 settings_view(
199 handle: repo.handle.as_str(),
200 name: repo.name.as_str(),
201 fields: Fields {
202 description: repo.description.as_deref().unwrap_or(""),
203 visibility: repo.visibility,
204 pinned: repo.pinned,
205 },
206 saved: false,
207 error: message.as_str(),
208 )
209 };
210 }
211
212 match delete_repo(
213 &current_actor(cx).await?,
214 &repo.handle,
215 &repo.name,
216 &orgs(cx),
217 &memberships(cx),
218 &repos(cx),
219 &storage(cx),
220 )
221 .await
222 {
223 Ok(()) => {}
224 Err(Error::Domain(DomainError::Forbidden | DomainError::NotFound { .. })) => {
225 return Err(not_found().into());
226 }
227 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
228 }
229
230 view! {
231 (StatusCode::SEE_OTHER)
232 (location(&format!("/{}", repo.handle))?)
233 }
234}
235
236/// The three values the form edits, grouped so the view takes one argument for them
237/// rather than three that must be kept in the same order at four call sites.
238#[derive(Debug, Clone, Copy)]
239struct Fields<'a> {
240 description: &'a str,
241 visibility: Visibility,
242 pinned: bool,
243}
244
245/// The settings page.
246///
247/// Values arrive as parameters rather than being read back from storage, so a rejected
248/// submission can re-render exactly what was typed.
249#[component]
250async fn settings_view(
251 handle: &str,
252 name: &str,
253 fields: Fields<'_>,
254 saved: bool,
255 error: &str,
256) -> Result {
257 let Fields {
258 description,
259 visibility,
260 pinned,
261 } = fields;
262
263 view! {
264 <h1 class="text-xl font-semibold tracking-tight">"Repository settings"</h1>
265 <p class="mt-1 font-mono text-sm text-muted-foreground">
266 "@" (handle) " / " (name)
267 </p>
268
269 if saved {
270 <div class="mt-6">
271 flash(kind: FlashKind::Success, "Repository updated.")
272 </div>
273 }
274
275 if !error.is_empty() {
276 <div class="mt-6">
277 flash(kind: FlashKind::Error, (error))
278 </div>
279 }
280
281 <form
282 method="post"
283 action=(format!("/{handle}/repos/{name}/settings"))
284 class="mt-6 space-y-5"
285 >
286 <div class="space-y-2">
287 label(attrs: attributes! { for="description" }, "Description")
288 textarea(
289 attrs: attributes! {
290 id="description"
291 name="description"
292 rows="2"
293 maxlength=(Repository::MAX_DESCRIPTION_LEN.to_string())
294 placeholder="A sentence for your profile."
295 },
296 (description)
297 )
298 <p class="text-xs text-muted-foreground">
299 "Optional. At most "
300 (Repository::MAX_DESCRIPTION_LEN.to_string()) " characters."
301 </p>
302 </div>
303
304 <div class="space-y-2">
305 label(attrs: attributes! { for="visibility" }, "Visibility")
306 select(
307 attrs: attributes! { id="visibility" name="visibility" },
308 <option value="public" selected=(visibility.is_public())>"Public"</option>
309 <option value="private" selected=(!visibility.is_public())>"Private"</option>
310 )
311 <p class="text-xs text-muted-foreground">
312 "A private repository is hidden from your profile and needs a token "
313 "to clone."
314 </p>
315 </div>
316
317 <div class="space-y-2">
318 <div class="flex items-start gap-2">
319 <input
320 id="pinned"
321 name="pinned"
322 type="checkbox"
323 value="on"
324 checked=(pinned)
325 class="mt-0.5"
326 />
327 label(attrs: attributes! { for="pinned" }, "Lead my profile with this")
328 </div>
329 <p class="text-xs text-muted-foreground">
330 "One repository at a time. Choosing this one lets go of whichever "
331 "was chosen before."
332 </p>
333 </div>
334
335 <div class="flex items-center gap-3">
336 button(attrs: attributes! { type="submit" }, "Save")
337 <a
338 href=(format!("/{handle}/repos/{name}"))
339 class="text-sm text-muted-foreground hover:text-foreground"
340 >"Back to repository"</a>
341 </div>
342 </form>
343
344 <section class="mt-10 rounded-lg border border-destructive/30 p-4">
345 <h2 class="text-xs font-medium uppercase tracking-wider text-destructive">
346 "Delete this repository"
347 </h2>
348 <p class="mt-2 text-sm text-muted-foreground">
349 "This cannot be undone. The commits, branches and tags go with it, and "
350 "anyone with a clone keeps their copy while this instance keeps nothing."
351 </p>
352
353 <form
354 method="post"
355 action=(format!("/{handle}/repos/{name}/settings/delete"))
356 class="mt-4 space-y-2"
357 >
358 label(
359 attrs: attributes! { for="confirm" },
360 "Type " (name) " to confirm"
361 )
362 input(attrs: attributes! {
363 id="confirm"
364 name="confirm"
365 type="text"
366 value=""
367 placeholder=(name)
368 required=(true)
369 autocomplete="off"
370 })
371 <div class="pt-1">
372 button(
373 attrs: attributes! { type="submit" },
374 variant: ButtonVariant::Destructive,
375 "Delete repository"
376 )
377 </div>
378 </form>
379 </section>
380 }
381}