steid

@jamesgill /

6.9 KBCode·Blame·Raw
c50d9adfeat: profile settings with flash feedback25d
1//! Profile settings — `/{handle}/settings`.
2//!
3//! Owner only. Authorization is the use case's decision, not this module's; the page
4//! only chooses how to render the answer.
5
6use serde::Deserialize;
7use topcoat::{
8 Result,
9 context::Cx,
285f5fdfeat: create and view repositories through the browser24d
10 router::{StatusCode, content::Form, error::forbidden, page, query_params},
c50d9adfeat: profile settings with flash feedback25d
11 view::{attributes, component, view},
12};
13
14use crate::{
15 application::{Error, update_profile},
16 components::{
17 button::button,
18 flash::{FlashKind, flash},
19 input::input,
20 label::label,
21 textarea::textarea,
22 },
23 domain::{DomainError, Organization},
24};
25
26use super::{
285f5fdfeat: create and view repositories through the browser24d
27 context::{current_actor, location, memberships, orgs, server_error},
5d3dfa5feat: a global top bar, and pages choose their own width23h
28 layout::narrow,
c50d9adfeat: profile settings with flash feedback25d
29 profile::profile_for,
30};
31
32#[derive(Debug, Deserialize)]
33struct ProfileForm {
34 display_name: String,
35 bio: String,
36}
37
38/// Set after a successful save so the confirmation survives the redirect.
39///
40/// Post-redirect-get: reloading after a save must not resubmit it.
41#[query_params(error = bad_request)]
42struct Saved {
43 saved: Option<String>,
44}
45
46/// Blank input means "clear this field", which the domain treats as unset.
47fn optional(value: &str) -> Option<String> {
48 Some(value.trim().to_owned()).filter(|value| !value.is_empty())
49}
50
51#[page("/{handle}/settings")]
52async fn settings_page(cx: &Cx) -> Result {
53 let profile = profile_for(cx).await?;
54
55 if !profile.viewer_is_owner {
56 return Err(forbidden().into());
57 }
58
59 let saved = query_params::<Saved>(cx)?.saved.is_some();
60
61 view! {
62 settings_form(
63 handle: profile.handle.as_str(),
64 // The stored display name, not the label: the label falls back to the
65 // handle, which would put text in a field the owner never typed.
66 display_name: profile.display_name.as_deref().unwrap_or(""),
67 bio: profile.bio.as_deref().unwrap_or(""),
68 saved: saved,
69 error: "",
70 )
71 }
72}
73
74/// Applies an edit.
75///
285f5fdfeat: create and view repositories through the browser24d
76/// Success replies 303, so a reload cannot resubmit — see [`location`] for why it is
77/// spelled this way and not with `redirect()`, which is a 307 and would re-POST this
78/// form to itself.
79///
80/// Failure re-renders with the message and **what was typed** — bouncing back to a
81/// blank form would throw away the work and leave the reason invisible, which is the
82/// whole problem `flash` exists to fix.
c50d9adfeat: profile settings with flash feedback25d
83#[page(POST "/{handle}/settings")]
84async fn save(cx: &Cx, Form(submitted): Form<ProfileForm>) -> Result {
85 let profile = profile_for(cx).await?;
86
87 let outcome = update_profile(
88 &current_actor(cx).await?,
89 &profile.handle,
90 optional(&submitted.display_name),
91 optional(&submitted.bio),
92 &orgs(cx),
93 &memberships(cx),
94 )
95 .await;
96
97 let message = match outcome {
98 Ok(()) => {
285f5fdfeat: create and view repositories through the browser24d
99 return view! {
100 (StatusCode::SEE_OTHER)
101 (location(&format!("/{}/settings?saved", profile.handle))?)
102 };
c50d9adfeat: profile settings with flash feedback25d
103 }
104 // The visitor's to fix, so it is shown.
105 Err(Error::Domain(DomainError::Validation { field, reason })) => {
106 format!("That {field} is no good: {reason}.")
107 }
108 Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
109 // Ours, so it is logged and answered generically.
110 Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
111 };
112
113 view! {
114 settings_form(
115 handle: profile.handle.as_str(),
116 display_name: submitted.display_name.as_str(),
117 bio: submitted.bio.as_str(),
118 saved: false,
119 error: message.as_str(),
120 )
121 }
122}
123
124/// The edit form.
125///
126/// Values arrive as parameters rather than being read back from storage, so a rejected
127/// submission can re-render exactly what was typed.
128#[component]
129async fn settings_form(
130 handle: &str,
131 display_name: &str,
132 bio: &str,
133 saved: bool,
134 error: &str,
135) -> Result {
136 view! {
5d3dfa5feat: a global top bar, and pages choose their own width23h
137 narrow(
138 <h1 class="text-xl font-semibold tracking-tight">"Profile settings"</h1>
139 <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
140
141 if saved {
142 <div class="mt-6">
143 flash(kind: FlashKind::Success, "Profile updated.")
144 </div>
145 }
146
147 if !error.is_empty() {
148 <div class="mt-6">
149 flash(kind: FlashKind::Error, (error))
150 </div>
151 }
152
153 <form method="post" action=(format!("/{handle}/settings")) class="mt-6 space-y-5">
154 <div class="space-y-2">
155 label(attrs: attributes! { for="display_name" }, "Display name")
156 input(attrs: attributes! {
157 id="display_name"
158 name="display_name"
159 type="text"
160 value=(display_name)
161 placeholder=(handle)
162 })
163 <p class="text-xs text-muted-foreground">
164 "Shown instead of your handle. Leave blank to use @" (handle) "."
165 </p>
166 </div>
167
168 <div class="space-y-2">
169 label(attrs: attributes! { for="bio" }, "Bio")
170 textarea(
171 attrs: attributes! {
172 id="bio"
173 name="bio"
174 rows="4"
175 maxlength=(Organization::MAX_BIO_LEN.to_string())
176 },
177 (bio)
178 )
179 <p class="text-xs text-muted-foreground">
180 "At most " (Organization::MAX_BIO_LEN.to_string()) " characters."
181 </p>
182 </div>
183
184 <div class="flex items-center gap-3">
185 button(attrs: attributes! { type="submit" }, "Save")
186 <a
187 href=(format!("/{handle}"))
188 class="text-sm text-muted-foreground hover:text-foreground"
189 >"Back to profile"</a>
190 </div>
191 </form>
192
193 <section class="mt-10 border-t border-border pt-6">
194 <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
195 "Access tokens"
196 </h2>
197 <p class="mt-2 text-sm text-muted-foreground">
198 "Tokens are how git authenticates when you push or clone a private "
199 "repository over HTTPS."
c50d9adfeat: profile settings with flash feedback25d
200 </p>
5d3dfa5feat: a global top bar, and pages choose their own width23h
201 <p class="mt-3">
202 <a
203 href=(format!("/{handle}/settings/tokens"))
204 class="text-sm font-medium hover:underline"
205 >"Manage tokens →"</a>
c50d9adfeat: profile settings with flash feedback25d
206 </p>
5d3dfa5feat: a global top bar, and pages choose their own width23h
207 </section>
208 )
c50d9adfeat: profile settings with flash feedback25d
209 }
210}