steid

@jamesgill /

feat: profile settings with flash feedback

Milestone 2 phase 2. /{handle}/settings edits display name and bio, owner
only, with the first real success and failure feedback in the product.

Success redirects (post-redirect-get, so a reload cannot resubmit); failure
re-renders with the message and what was typed. Bouncing back to a blank
form would discard the work and hide the reason, which is the exact problem
flash was built to solve -- so the error path is the one that matters here,
not the happy one.

Validation failures are reported because they are the visitor's to fix.
Anything else is logged and answered generically, so a storage fault cannot
masquerade as bad input.

PublicProfile now carries display_name separately from label. label falls
back to the handle, which is right for rendering and wrong for an edit form
-- it would prefill a value the owner never typed.

DomainError gains Forbidden, distinct from NotFound: the profile is public
anyway, so pretending it does not exist would be theatre rather than privacy.

Two corrections to my own earlier draft: components are invoked bare inside
view! rather than wrapped in (..?), and if/match/for are native to the macro;
and #[query_params] needs error = ... to be usable with ?, since otherwise
the error borrows from cx and escapes the handler.

Verified running: GET as owner 200 with an empty display-name field, signed
out 403. A valid save redirects to ?saved and shows role="status"; no flash
without the flag. An over-long bio re-renders 200 with role="alert", the
reason, both typed values intact, and the stored profile unchanged. A signed
-out POST is 403 with nothing written. Clearing both fields makes the profile
fall back to the handle.

117 tests. Milestone 2 done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 24 days agoparentaf126c7Browse filesc50d9add71580a4a54e54b13e648136b3daa8d4e

8 files changed+396 −11

plans/ROADMAP.md+2 −2View file
@@ -52,8 +52,8 @@ a baseline.
5252 |---|---|---|
5353 | 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | done |
5454 | 1 | **Identity, thin** — claim on first run, login, session | done |
55| 2 | **Profile page** — `/{handle}` as the real profile | active |
56| 3 | **Writing** — posts, markdown | not started |
55+| 2 | **Profile page** — `/{handle}` as the real profile | done |
56+| 3 | **Writing** — posts, markdown | active |
5757 | 4 | **Repo model** — records + bare repos on disk | not started |
5858 | 5 | **Git over HTTP** — `git http-backend`, PATs for auth | not started |
5959 | 6 | **Repo browsing** — tree, blob, commit log | not started |
plans/current.md+16 −4View file
@@ -38,11 +38,10 @@ A profile you can't change is a stub. This is what makes it a portfolio page.
3838
3939 - [x] Styling: Tailwind via Topcoat, theme retuned, primitives copied in, `flash`
4040 written by hand ([0005](decisions/0005-tailwind-and-copied-components.md))
41- [ ] Flash messages — the first edit form needs success and failure feedback, and
42 every form after it inherits whatever we build here
43- [ ] `/{handle}/settings` — edit display name and bio, owner only, enforced in the use
41+- [x] Flash messages — `flash` component, hand-written; the registry has no alert
42+- [x] `/{handle}/settings` — edit display name and bio, owner only, enforced in the use
4443 case (settings belong to the org, and this scales to organisations)
45- [ ] Owner-only affordances on the profile (edit link)
44+- [x] Owner-only affordances on the profile (edit link)
4645
4746 ### Done when
4847
@@ -50,6 +49,19 @@ Signed out, `/{handle}` renders the owner's display name and handle and nothing
5049 private. An unknown handle 404s. The owner can set a display name and bio and see them
5150 on the page. `/api/users/{handle}` returns the same public view.
5251
52+### Findings — phase 2
53+
54+- **Components are invoked bare inside `view!`** — `label(attrs: …, "Text")`, not
55+ `(label(…)?)`. `if`, `match`, `for` and `let` are native to the macro too, so the
56+ wrapper-block pattern is unnecessary.
57+- **`#[query_params]` needs `error = …`** to be usable with `?`. Without it the `Err`
58+ side borrows from `cx` and the borrow escapes the handler.
59+- **Post-redirect-get on success, re-render on failure.** A redirect after an error
60+ would discard what was typed and lose the reason — the exact problem `flash` exists
61+ to solve.
62+- `PublicProfile` carries `display_name` separately from `label`, so an edit form can
63+ leave the field empty rather than prefilling the handle.
64+
5365 ### Findings — phase 1
5466
5567 - **`path_param` is an attribute in Topcoat 0.5**, applied to a tuple struct
plans/progress.md+11 −2View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5111 tests. Active milestone in [current.md](current.md).
5+117 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -59,7 +59,7 @@ in; re-claiming a claimed instance is refused.
5959 [runbook.md](runbook.md#steid_insecure_cookies--development-only). This one actually
6060 bit, and it looked exactly like broken auth logic.
6161
62### Milestone 2 — Profile page · phase 1 done
62+### Milestone 2 — Profile page · done
6363
6464 `/{handle}` is the real profile page: label, handle, bio, and the section frame for
6565 repositories, writing, and projects. Public, renders signed out, 404s on an unknown
@@ -87,6 +87,15 @@ URLs settled as root handles with grouped application routes
8787 accented text well under the limit.
8888 - **`path_param` is an attribute macro in 0.5**, not function-like. The vendored crate
8989 is the authority for the pinned version, not the docs on `main`.
90+- **Components are invoked bare inside `view!`**, and `if`/`match`/`for`/`let` are
91+ native to the macro.
92+- **`#[query_params]` needs `error = …`** to work with `?`; otherwise the error borrows
93+ from `cx` and escapes the handler.
94+- **Forms re-render on failure and redirect on success.** Redirecting after a
95+ validation error throws away what was typed and hides the reason.
96+- **Styling is Tailwind via Topcoat's build script**, with registry components copied
97+ in rather than depended on ([0005](decisions/0005-tailwind-and-copied-components.md)).
98+ Components reference theme tokens, never raw colours.
9099
91100 ---
92101
src/application/mod.rs+1 −1View file
@@ -17,5 +17,5 @@ pub use config::AppConfig;
1717 pub use error::{Error, Result};
1818 pub use identity::{Identity, describe_identity};
1919 pub use login::login;
20pub use profile::{PublicProfile, view_profile};
20+pub use profile::{PublicProfile, update_profile, view_profile};
2121 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
src/application/profile.rs+171 −2View file
@@ -1,5 +1,5 @@
11 use crate::domain::{
2 Actor, OrgName, Organization, Role,
2+ Actor, DomainError, OrgName, Organization, Role,
33 repository::{MembershipRepository, OrgRepository},
44 };
55
@@ -16,8 +16,14 @@ use super::error::Result;
1616 #[derive(Debug, Clone, PartialEq, Eq)]
1717 pub struct PublicProfile {
1818 pub handle: OrgName,
19 /// Display name if set, otherwise the handle.
19+ /// Display name if set, otherwise the handle. What a page should show.
2020 pub label: String,
21+ /// The stored display name, unset if there isn't one.
22+ ///
23+ /// Distinct from `label` so an edit form can leave the field empty rather than
24+ /// prefilling the handle, which the owner never typed. Public because `label`
25+ /// already reveals it whenever it is set.
26+ pub display_name: Option<String>,
2127 pub bio: Option<String>,
2228 /// Whether the viewer may edit this profile. Decided here rather than in the page,
2329 /// so the web form and `/api` cannot disagree about it.
@@ -29,6 +35,7 @@ impl PublicProfile {
2935 Self {
3036 handle: org.name.clone(),
3137 label: org.label().to_owned(),
38+ display_name: org.display_name.clone(),
3239 bio: org.bio.clone(),
3340 viewer_is_owner,
3441 }
@@ -71,6 +78,34 @@ async fn is_owner(
7178 .is_some_and(|membership| membership.role == Role::Owner))
7279 }
7380
81+/// Edits a profile's display name and bio.
82+///
83+/// Authorization lives here, not in the page: the web form and any future `/api`
84+/// caller must get the same answer about who may edit. Owners only — a member's
85+/// read access is not edit access.
86+pub async fn update_profile(
87+ actor: &Actor,
88+ handle: &OrgName,
89+ display_name: Option<String>,
90+ bio: Option<String>,
91+ orgs: &impl OrgRepository,
92+ memberships: &impl MembershipRepository,
93+) -> Result<()> {
94+ let Some(mut org) = orgs.find_by_name(handle).await? else {
95+ return Err(DomainError::NotFound { entity: "profile" }.into());
96+ };
97+
98+ if !is_owner(&org, actor, memberships).await? {
99+ return Err(DomainError::Forbidden.into());
100+ }
101+
102+ // Validates before saving, so a rejected edit leaves the stored profile untouched.
103+ org.update_profile(display_name, bio)?;
104+ orgs.save(&org).await?;
105+
106+ Ok(())
107+}
108+
74109 #[cfg(test)]
75110 mod tests {
76111 use super::*;
@@ -238,6 +273,140 @@ mod tests {
238273 .expect("should resolve");
239274
240275 assert_eq!(profile.label, "bare");
276+ assert_eq!(
277+ profile.display_name, None,
278+ "the label falls back to the handle, but display_name stays unset so an \
279+ edit form does not prefill a value the owner never typed"
280+ );
241281 assert_eq!(profile.bio, None);
242282 }
283+ #[tokio::test]
284+ async fn the_owner_can_edit() {
285+ let f = fixture().await;
286+
287+ update_profile(
288+ &Actor::User(f.owner.clone()),
289+ &f.org.name,
290+ Some("Renamed".to_owned()),
291+ Some("New bio.".to_owned()),
292+ &f.orgs,
293+ &f.memberships,
294+ )
295+ .await
296+ .expect("owner may edit");
297+
298+ let profile = view(&f, &Actor::Anonymous).await.expect("resolve");
299+ assert_eq!(profile.label, "Renamed");
300+ assert_eq!(profile.bio.as_deref(), Some("New bio."));
301+ }
302+
303+ async fn expect_rejected(f: &Fixture, actor: &Actor) -> crate::application::Error {
304+ update_profile(
305+ actor,
306+ &f.org.name,
307+ Some("Hijacked".to_owned()),
308+ None,
309+ &f.orgs,
310+ &f.memberships,
311+ )
312+ .await
313+ .expect_err("should reject")
314+ }
315+
316+ #[tokio::test]
317+ async fn an_anonymous_actor_cannot_edit() {
318+ let f = fixture().await;
319+
320+ let error = expect_rejected(&f, &Actor::Anonymous).await;
321+
322+ assert!(matches!(
323+ error,
324+ crate::application::Error::Domain(DomainError::Forbidden)
325+ ));
326+ assert_eq!(
327+ view(&f, &Actor::Anonymous).await.expect("resolve").label,
328+ "Acme Inc",
329+ "a rejected edit must change nothing"
330+ );
331+ }
332+
333+ #[tokio::test]
334+ async fn a_signed_in_stranger_cannot_edit() {
335+ let f = fixture().await;
336+
337+ let error = expect_rejected(&f, &Actor::User(UserId::generate())).await;
338+
339+ assert!(matches!(
340+ error,
341+ crate::application::Error::Domain(DomainError::Forbidden)
342+ ));
343+ }
344+
345+ #[tokio::test]
346+ async fn a_member_who_is_not_an_owner_cannot_edit_either() {
347+ let f = fixture().await;
348+ let member = UserId::generate();
349+ f.memberships
350+ .save(&Membership::new(
351+ MembershipId::generate(),
352+ f.org.id.clone(),
353+ member.clone(),
354+ Role::Member,
355+ ))
356+ .await
357+ .expect("save membership");
358+
359+ let error = expect_rejected(&f, &Actor::User(member)).await;
360+
361+ assert!(matches!(
362+ error,
363+ crate::application::Error::Domain(DomainError::Forbidden)
364+ ));
365+ }
366+
367+ #[tokio::test]
368+ async fn editing_an_unknown_handle_is_not_found() {
369+ let f = fixture().await;
370+
371+ let error = update_profile(
372+ &Actor::User(f.owner.clone()),
373+ &OrgName::new("nobody").expect("valid"),
374+ None,
375+ None,
376+ &f.orgs,
377+ &f.memberships,
378+ )
379+ .await
380+ .expect_err("should reject");
381+
382+ assert!(matches!(
383+ error,
384+ crate::application::Error::Domain(DomainError::NotFound { .. })
385+ ));
386+ }
387+
388+ #[tokio::test]
389+ async fn an_invalid_edit_leaves_the_stored_profile_untouched() {
390+ let f = fixture().await;
391+
392+ let error = update_profile(
393+ &Actor::User(f.owner.clone()),
394+ &f.org.name,
395+ Some("Renamed".to_owned()),
396+ Some("a".repeat(Organization::MAX_BIO_LEN + 1)),
397+ &f.orgs,
398+ &f.memberships,
399+ )
400+ .await
401+ .expect_err("should reject");
402+
403+ assert!(matches!(
404+ error,
405+ crate::application::Error::Domain(DomainError::Validation { .. })
406+ ));
407+
408+ let profile = view(&f, &Actor::Anonymous).await.expect("resolve");
409+ assert_eq!(profile.label, "Acme Inc");
410+ assert_eq!(profile.bio.as_deref(), Some("We make things."));
411+ }
243412 }
src/domain/error.rs+6 −0View file
@@ -14,6 +14,11 @@ pub enum DomainError {
1414 AlreadyExists { entity: &'static str },
1515 /// Credentials did not match. Deliberately says nothing about which part failed.
1616 InvalidCredentials,
17+ /// The actor is known but not permitted to do this.
18+ ///
19+ /// Distinct from [`NotFound`](Self::NotFound): used where the resource is public
20+ /// anyway, so pretending it does not exist would be theatre rather than privacy.
21+ Forbidden,
1722 }
1823
1924 impl DomainError {
@@ -32,6 +37,7 @@ impl fmt::Display for DomainError {
3237 Self::NotFound { entity } => write!(f, "{entity} not found"),
3338 Self::AlreadyExists { entity } => write!(f, "{entity} already exists"),
3439 Self::InvalidCredentials => f.write_str("invalid credentials"),
40+ Self::Forbidden => f.write_str("not permitted"),
3541 }
3642 }
3743 }
src/infrastructure/web/mod.rs+1 −0View file
@@ -6,4 +6,5 @@ pub mod layout;
66 pub mod pages;
77 pub mod profile;
88 pub mod session_cookie;
9+pub mod settings;
910 pub mod setup;
src/infrastructure/web/settings.rs+188 −0View file
@@ -0,0 +1,188 @@
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+
6+use serde::Deserialize;
7+use topcoat::{
8+ Result,
9+ context::Cx,
10+ router::{
11+ content::Form,
12+ error::{forbidden, redirect},
13+ page, query_params,
14+ },
15+ view::{attributes, component, view},
16+};
17+
18+use crate::{
19+ application::{Error, update_profile},
20+ components::{
21+ button::button,
22+ flash::{FlashKind, flash},
23+ input::input,
24+ label::label,
25+ textarea::textarea,
26+ },
27+ domain::{DomainError, Organization},
28+};
29+
30+use super::{
31+ context::{current_actor, memberships, orgs, server_error},
32+ profile::profile_for,
33+};
34+
35+#[derive(Debug, Deserialize)]
36+struct ProfileForm {
37+ display_name: String,
38+ bio: String,
39+}
40+
41+/// Set after a successful save so the confirmation survives the redirect.
42+///
43+/// Post-redirect-get: reloading after a save must not resubmit it.
44+#[query_params(error = bad_request)]
45+struct Saved {
46+ saved: Option<String>,
47+}
48+
49+/// Blank input means "clear this field", which the domain treats as unset.
50+fn optional(value: &str) -> Option<String> {
51+ Some(value.trim().to_owned()).filter(|value| !value.is_empty())
52+}
53+
54+#[page("/{handle}/settings")]
55+async fn settings_page(cx: &Cx) -> Result {
56+ let profile = profile_for(cx).await?;
57+
58+ if !profile.viewer_is_owner {
59+ return Err(forbidden().into());
60+ }
61+
62+ let saved = query_params::<Saved>(cx)?.saved.is_some();
63+
64+ view! {
65+ settings_form(
66+ handle: profile.handle.as_str(),
67+ // The stored display name, not the label: the label falls back to the
68+ // handle, which would put text in a field the owner never typed.
69+ display_name: profile.display_name.as_deref().unwrap_or(""),
70+ bio: profile.bio.as_deref().unwrap_or(""),
71+ saved: saved,
72+ error: "",
73+ )
74+ }
75+}
76+
77+/// Applies an edit.
78+///
79+/// Success redirects, so a reload cannot resubmit. Failure re-renders with the message
80+/// and **what was typed** — bouncing back to a blank form would throw away the work and
81+/// leave the reason invisible, which is the whole problem `flash` exists to fix.
82+#[page(POST "/{handle}/settings")]
83+async 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 Err(redirect(&format!("/{}/settings?saved", profile.handle)).into());
99+ }
100+ // The visitor's to fix, so it is shown.
101+ Err(Error::Domain(DomainError::Validation { field, reason })) => {
102+ format!("That {field} is no good: {reason}.")
103+ }
104+ Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
105+ // Ours, so it is logged and answered generically.
106+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
107+ };
108+
109+ view! {
110+ settings_form(
111+ handle: profile.handle.as_str(),
112+ display_name: submitted.display_name.as_str(),
113+ bio: submitted.bio.as_str(),
114+ saved: false,
115+ error: message.as_str(),
116+ )
117+ }
118+}
119+
120+/// The edit form.
121+///
122+/// Values arrive as parameters rather than being read back from storage, so a rejected
123+/// submission can re-render exactly what was typed.
124+#[component]
125+async fn settings_form(
126+ handle: &str,
127+ display_name: &str,
128+ bio: &str,
129+ saved: bool,
130+ error: &str,
131+) -> Result {
132+ view! {
133+ <h1 class="text-xl font-semibold tracking-tight">"Profile settings"</h1>
134+ <p class="mt-1 font-mono text-sm text-muted-foreground">"@" (handle)</p>
135+
136+ if saved {
137+ <div class="mt-6">
138+ flash(kind: FlashKind::Success, "Profile updated.")
139+ </div>
140+ }
141+
142+ if !error.is_empty() {
143+ <div class="mt-6">
144+ flash(kind: FlashKind::Error, (error))
145+ </div>
146+ }
147+
148+ <form method="post" action=(format!("/{handle}/settings")) class="mt-6 space-y-5">
149+ <div class="space-y-2">
150+ label(attrs: attributes! { for="display_name" }, "Display name")
151+ input(attrs: attributes! {
152+ id="display_name"
153+ name="display_name"
154+ type="text"
155+ value=(display_name)
156+ placeholder=(handle)
157+ })
158+ <p class="text-xs text-muted-foreground">
159+ "Shown instead of your handle. Leave blank to use @" (handle) "."
160+ </p>
161+ </div>
162+
163+ <div class="space-y-2">
164+ label(attrs: attributes! { for="bio" }, "Bio")
165+ textarea(
166+ attrs: attributes! {
167+ id="bio"
168+ name="bio"
169+ rows="4"
170+ maxlength=(Organization::MAX_BIO_LEN.to_string())
171+ },
172+ (bio)
173+ )
174+ <p class="text-xs text-muted-foreground">
175+ "At most " (Organization::MAX_BIO_LEN.to_string()) " characters."
176+ </p>
177+ </div>
178+
179+ <div class="flex items-center gap-3">
180+ button(attrs: attributes! { type="submit" }, "Save")
181+ <a
182+ href=(format!("/{handle}"))
183+ class="text-sm text-muted-foreground hover:text-foreground"
184+ >"Back to profile"</a>
185+ </div>
186+ </form>
187+ }
188+}