@jpgilldev / steid

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