steid

@jamesgill /

feat: public profile page at /{handle}

Milestone 2 phase 1. The profile is the product, so this replaces the
placeholder as the page everything else will hang off.

PublicProfile deliberately has no email field. Identity does, and /api/me
returns it, because that endpoint describes the caller to themselves --
reusing it here would have published the owner's address to every anonymous
visitor. Making the type reaching the page have nowhere to put an email turns
that from a mistake to avoid into one that cannot be made.

viewer_is_owner is decided in the use case rather than the page, so the web
form and /api cannot disagree about who may edit. A signed-in stranger and a
non-owner member both get false; there are tests for each, since "signed in"
quietly becoming "allowed" is the usual way this goes wrong.

Organization gains bio, set through update_profile rather than the
constructor -- nothing creating an organisation has one to supply. Blank
input clears rather than storing whitespace, so cleared and never-set are the
same state and the page renders one case. A rejected edit applies nothing.

Notes on Topcoat 0.5: path_param is an attribute on a tuple struct
(#[path_param] struct Handle(str)), not the function-like macro its docs on
main describe -- the vendored crate is the authority for our version. A str
inner type yields the raw segment, which suits validating with OrgName and
404ing a handle that fails.

Verified running: /auth/login and /api/me still route with /{handle} at the
root, so static beats parameterised as assumed. Signed out, /jimbo renders
label, handle, bio and the section frame with no edit link and zero
occurrences of the owner's email in HTML or JSON. The owner sees the edit
link and viewer_is_owner true. /JIMBO and /JiMbO resolve. Malformed handles
404 rather than 500. / forwards a signed-in owner to their profile.

111 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparent87edb56Browse filesa69e38084bc628bcb9061277927b6be78401ec98

10 files changed+534 −28

migrations/20260804093034_add_org_bio.sql+3 −0View file
@@ -0,0 +1,3 @@
1+-- Free-form profile text shown on /{handle}. Nullable: a profile without one renders
2+-- without the section rather than with an empty one.
3+alter table orgs add column bio text;
src/application/mod.rs+2 −0View file
@@ -9,6 +9,7 @@ pub mod error;
99 pub mod identity;
1010 pub mod login;
1111 pub mod port;
12+pub mod profile;
1213 pub mod session;
1314
1415 pub use claim::{OwnerSpec, claim_instance, is_claimed};
@@ -16,4 +17,5 @@ pub use config::AppConfig;
1617 pub use error::{Error, Result};
1718 pub use identity::{Identity, describe_identity};
1819 pub use login::login;
20+pub use profile::{PublicProfile, view_profile};
1921 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
src/application/profile.rs+243 −0View file
@@ -0,0 +1,243 @@
1+use crate::domain::{
2+ Actor, OrgName, Organization, Role,
3+ repository::{MembershipRepository, OrgRepository},
4+};
5+
6+use super::error::Result;
7+
8+/// A profile as anyone may see it.
9+///
10+/// **Deliberately carries no private data.** There is no email field, and there must
11+/// never be one: this type is rendered to anonymous visitors, and the safest way to
12+/// avoid leaking the owner's email is for the type reaching the page to have nowhere to
13+/// put it. [`Identity`](super::Identity) is the private counterpart — it does carry an
14+/// email, and `/api/me` returns it, because that endpoint describes the caller to
15+/// themselves.
16+#[derive(Debug, Clone, PartialEq, Eq)]
17+pub struct PublicProfile {
18+ pub handle: OrgName,
19+ /// Display name if set, otherwise the handle.
20+ pub label: String,
21+ pub bio: Option<String>,
22+ /// Whether the viewer may edit this profile. Decided here rather than in the page,
23+ /// so the web form and `/api` cannot disagree about it.
24+ pub viewer_is_owner: bool,
25+}
26+
27+impl PublicProfile {
28+ fn of(org: &Organization, viewer_is_owner: bool) -> Self {
29+ Self {
30+ handle: org.name.clone(),
31+ label: org.label().to_owned(),
32+ bio: org.bio.clone(),
33+ viewer_is_owner,
34+ }
35+ }
36+}
37+
38+/// Resolves a handle into a publicly viewable profile.
39+///
40+/// `Ok(None)` when no such handle exists — the caller renders a 404. Anonymous viewers
41+/// are served the same profile as anyone else; only `viewer_is_owner` differs.
42+pub async fn view_profile(
43+ handle: &OrgName,
44+ actor: &Actor,
45+ orgs: &impl OrgRepository,
46+ memberships: &impl MembershipRepository,
47+) -> Result<Option<PublicProfile>> {
48+ let Some(org) = orgs.find_by_name(handle).await? else {
49+ return Ok(None);
50+ };
51+
52+ Ok(Some(PublicProfile::of(
53+ &org,
54+ is_owner(&org, actor, memberships).await?,
55+ )))
56+}
57+
58+/// Whether the actor owns this organisation.
59+async fn is_owner(
60+ org: &Organization,
61+ actor: &Actor,
62+ memberships: &impl MembershipRepository,
63+) -> Result<bool> {
64+ let Some(user_id) = actor.user_id() else {
65+ return Ok(false);
66+ };
67+
68+ Ok(memberships
69+ .find(&org.id, user_id)
70+ .await?
71+ .is_some_and(|membership| membership.role == Role::Owner))
72+}
73+
74+#[cfg(test)]
75+mod tests {
76+ use super::*;
77+ use crate::{
78+ domain::{Membership, MembershipId, OrgId, UserId},
79+ infrastructure::repository::{InMemoryMembershipRepo, InMemoryOrgRepo},
80+ };
81+
82+ struct Fixture {
83+ orgs: InMemoryOrgRepo,
84+ memberships: InMemoryMembershipRepo,
85+ owner: UserId,
86+ org: Organization,
87+ }
88+
89+ async fn fixture() -> Fixture {
90+ let orgs = InMemoryOrgRepo::new();
91+ let memberships = InMemoryMembershipRepo::new();
92+
93+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
94+ org.update_profile(
95+ Some("Acme Inc".to_owned()),
96+ Some("We make things.".to_owned()),
97+ )
98+ .expect("valid profile");
99+ orgs.save(&org).await.expect("save org");
100+
101+ let owner = UserId::generate();
102+ memberships
103+ .save(&Membership::new(
104+ MembershipId::generate(),
105+ org.id.clone(),
106+ owner.clone(),
107+ Role::Owner,
108+ ))
109+ .await
110+ .expect("save membership");
111+
112+ Fixture {
113+ orgs,
114+ memberships,
115+ owner,
116+ org,
117+ }
118+ }
119+
120+ async fn view(f: &Fixture, actor: &Actor) -> Option<PublicProfile> {
121+ view_profile(&f.org.name, actor, &f.orgs, &f.memberships)
122+ .await
123+ .expect("view")
124+ }
125+
126+ #[tokio::test]
127+ async fn an_anonymous_visitor_sees_the_profile() {
128+ let f = fixture().await;
129+
130+ let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
131+
132+ assert_eq!(profile.handle.as_str(), "acme");
133+ assert_eq!(profile.label, "Acme Inc");
134+ assert_eq!(profile.bio.as_deref(), Some("We make things."));
135+ }
136+
137+ #[tokio::test]
138+ async fn an_anonymous_visitor_is_not_the_owner() {
139+ let f = fixture().await;
140+
141+ let profile = view(&f, &Actor::Anonymous).await.expect("should resolve");
142+
143+ assert!(!profile.viewer_is_owner);
144+ }
145+
146+ #[tokio::test]
147+ async fn the_owner_is_recognised() {
148+ let f = fixture().await;
149+
150+ let profile = view(&f, &Actor::User(f.owner.clone()))
151+ .await
152+ .expect("should resolve");
153+
154+ assert!(profile.viewer_is_owner);
155+ }
156+
157+ #[tokio::test]
158+ async fn a_signed_in_stranger_is_not_the_owner() {
159+ let f = fixture().await;
160+
161+ let profile = view(&f, &Actor::User(UserId::generate()))
162+ .await
163+ .expect("should resolve");
164+
165+ assert!(
166+ !profile.viewer_is_owner,
167+ "being signed in must not confer ownership of someone else's profile"
168+ );
169+ }
170+
171+ #[tokio::test]
172+ async fn a_member_who_is_not_an_owner_cannot_edit() {
173+ let f = fixture().await;
174+ let member = UserId::generate();
175+ f.memberships
176+ .save(&Membership::new(
177+ MembershipId::generate(),
178+ f.org.id.clone(),
179+ member.clone(),
180+ Role::Member,
181+ ))
182+ .await
183+ .expect("save membership");
184+
185+ let profile = view(&f, &Actor::User(member))
186+ .await
187+ .expect("should resolve");
188+
189+ assert!(
190+ !profile.viewer_is_owner,
191+ "membership alone is read access, not edit access"
192+ );
193+ }
194+
195+ #[tokio::test]
196+ async fn an_unknown_handle_resolves_to_none() {
197+ let f = fixture().await;
198+
199+ let profile = view_profile(
200+ &OrgName::new("nobody").expect("valid"),
201+ &Actor::Anonymous,
202+ &f.orgs,
203+ &f.memberships,
204+ )
205+ .await
206+ .expect("view");
207+
208+ assert_eq!(profile, None);
209+ }
210+
211+ #[tokio::test]
212+ async fn a_handle_resolves_regardless_of_the_casing_used() {
213+ let f = fixture().await;
214+
215+ // `OrgName::new` lowercases, so /ACME and /acme are the same handle.
216+ let profile = view_profile(
217+ &OrgName::new("ACME").expect("valid"),
218+ &Actor::Anonymous,
219+ &f.orgs,
220+ &f.memberships,
221+ )
222+ .await
223+ .expect("view");
224+
225+ assert!(profile.is_some(), "handles are case-insensitive");
226+ }
227+
228+ #[tokio::test]
229+ async fn a_profile_without_a_display_name_labels_with_its_handle() {
230+ let orgs = InMemoryOrgRepo::new();
231+ let memberships = InMemoryMembershipRepo::new();
232+ let org = Organization::new(OrgId::generate(), "bare", None).expect("valid org");
233+ orgs.save(&org).await.expect("save");
234+
235+ let profile = view_profile(&org.name, &Actor::Anonymous, &orgs, &memberships)
236+ .await
237+ .expect("view")
238+ .expect("should resolve");
239+
240+ assert_eq!(profile.label, "bare");
241+ assert_eq!(profile.bio, None);
242+ }
243+}
src/domain/org.rs+140 −2View file
@@ -112,10 +112,19 @@ pub struct Organization {
112112 pub name: OrgName,
113113 /// Free-form label shown in the UI. Falls back to `name` when unset.
114114 pub display_name: Option<String>,
115+ /// Free-form profile text shown on the profile page.
116+ pub bio: Option<String>,
115117 }
116118
117119 impl Organization {
120+ /// Longest permitted bio. Generous enough for a few sentences of introduction
121+ /// without becoming a page of its own.
122+ pub const MAX_BIO_LEN: usize = 500;
123+
118124 /// Creates an organisation from user-supplied input.
125+ ///
126+ /// Profile text is set afterwards with [`update_profile`](Self::update_profile) —
127+ /// nothing creating an organisation has one to supply.
119128 pub fn new(
120129 id: OrgId,
121130 name: impl Into<String>,
@@ -124,19 +133,52 @@ impl Organization {
124133 Ok(Self {
125134 id,
126135 name: OrgName::new(name)?,
127 display_name: display_name.filter(|value| !value.trim().is_empty()),
136+ display_name: normalise_optional(display_name),
137+ bio: None,
128138 })
129139 }
130140
131141 /// Reassembles an organisation from storage, skipping validation.
132 pub fn from_trusted(id: OrgId, name: OrgName, display_name: Option<String>) -> Self {
142+ pub fn from_trusted(
143+ id: OrgId,
144+ name: OrgName,
145+ display_name: Option<String>,
146+ bio: Option<String>,
147+ ) -> Self {
133148 Self {
134149 id,
135150 name,
136151 display_name,
152+ bio,
137153 }
138154 }
139155
156+ /// Applies edited profile fields, validating them.
157+ ///
158+ /// Blank input clears the field rather than storing whitespace, so "cleared" and
159+ /// "never set" stay the same state and the page has one case to render.
160+ pub fn update_profile(
161+ &mut self,
162+ display_name: Option<String>,
163+ bio: Option<String>,
164+ ) -> Result<(), DomainError> {
165+ let bio = normalise_optional(bio);
166+
167+ if let Some(bio) = &bio
168+ && bio.chars().count() > Self::MAX_BIO_LEN
169+ {
170+ return Err(DomainError::validation(
171+ "bio",
172+ format!("must be at most {} characters", Self::MAX_BIO_LEN),
173+ ));
174+ }
175+
176+ self.display_name = normalise_optional(display_name);
177+ self.bio = bio;
178+
179+ Ok(())
180+ }
181+
140182 /// The label to show in the UI.
141183 pub fn label(&self) -> &str {
142184 self.display_name
@@ -145,6 +187,13 @@ impl Organization {
145187 }
146188 }
147189
190+/// Trims, and treats blank as absent.
191+fn normalise_optional(value: Option<String>) -> Option<String> {
192+ value
193+ .map(|value| value.trim().to_owned())
194+ .filter(|value| !value.is_empty())
195+}
196+
148197 #[cfg(test)]
149198 mod tests {
150199 use super::*;
@@ -273,6 +322,95 @@ mod tests {
273322 assert_eq!(org.label(), "Acme");
274323 }
275324
325+ #[test]
326+ fn a_new_organisation_has_no_bio() {
327+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
328+
329+ assert_eq!(org.bio, None);
330+ }
331+
332+ #[test]
333+ fn update_profile_sets_both_fields() {
334+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
335+
336+ org.update_profile(
337+ Some("Acme Inc".to_owned()),
338+ Some("We make things.".to_owned()),
339+ )
340+ .expect("valid profile");
341+
342+ assert_eq!(org.label(), "Acme Inc");
343+ assert_eq!(org.bio.as_deref(), Some("We make things."));
344+ }
345+
346+ #[test]
347+ fn update_profile_trims_and_clears_blank_input() {
348+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
349+ org.update_profile(Some("Acme".to_owned()), Some("Hello".to_owned()))
350+ .expect("valid profile");
351+
352+ org.update_profile(Some(" ".to_owned()), Some(String::new()))
353+ .expect("valid profile");
354+
355+ // Cleared and never-set are the same state, so the page renders one case.
356+ assert_eq!(org.display_name, None);
357+ assert_eq!(org.bio, None);
358+ assert_eq!(org.label(), "acme");
359+ }
360+
361+ #[test]
362+ fn update_profile_trims_surrounding_whitespace() {
363+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
364+
365+ org.update_profile(None, Some(" spaced ".to_owned()))
366+ .expect("valid profile");
367+
368+ assert_eq!(org.bio.as_deref(), Some("spaced"));
369+ }
370+
371+ #[test]
372+ fn update_profile_accepts_a_bio_at_the_length_limit() {
373+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
374+
375+ assert!(
376+ org.update_profile(None, Some("a".repeat(Organization::MAX_BIO_LEN)))
377+ .is_ok()
378+ );
379+ }
380+
381+ #[test]
382+ fn update_profile_rejects_an_over_long_bio_and_changes_nothing() {
383+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
384+ org.update_profile(Some("Acme".to_owned()), Some("original".to_owned()))
385+ .expect("valid profile");
386+
387+ let error = org
388+ .update_profile(
389+ Some("Changed".to_owned()),
390+ Some("a".repeat(Organization::MAX_BIO_LEN + 1)),
391+ )
392+ .expect_err("should reject");
393+
394+ assert!(matches!(error, DomainError::Validation { .. }));
395+ assert_eq!(
396+ org.bio.as_deref(),
397+ Some("original"),
398+ "a rejected edit must not partially apply"
399+ );
400+ assert_eq!(org.label(), "Acme");
401+ }
402+
403+ #[test]
404+ fn bio_length_counts_characters_not_bytes() {
405+ let mut org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
406+
407+ // Multi-byte characters would trip a byte-length check well under the limit.
408+ assert!(
409+ org.update_profile(None, Some("é".repeat(Organization::MAX_BIO_LEN)))
410+ .is_ok()
411+ );
412+ }
413+
276414 #[test]
277415 fn blank_display_names_are_treated_as_unset() {
278416 let org =
src/infrastructure/repository/sqlite.rs+12 −5View file
@@ -106,6 +106,7 @@ impl SqliteOrgRepo {
106106 OrgId::from_trusted(row.get::<String, _>("id")),
107107 OrgName::from_trusted(row.get::<String, _>("name")),
108108 row.get::<Option<String>, _>("display_name"),
109+ row.get::<Option<String>, _>("bio"),
109110 )
110111 }
111112 }
@@ -133,15 +134,17 @@ impl OrgRepository for SqliteOrgRepo {
133134
134135 async fn save(&self, org: &Organization) -> RepositoryResult<()> {
135136 sqlx::query(
136 "insert into orgs (id, name, display_name)
137 values (?, ?, ?)
137+ "insert into orgs (id, name, display_name, bio)
138+ values (?, ?, ?, ?)
138139 on conflict (id) do update set
139140 name = excluded.name,
140 display_name = excluded.display_name",
141+ display_name = excluded.display_name,
142+ bio = excluded.bio",
141143 )
142144 .bind(org.id.as_str())
143145 .bind(org.name.as_str())
144146 .bind(org.display_name.as_deref())
147+ .bind(org.bio.as_deref())
145148 .execute(&self.pool)
146149 .await
147150 .map_err(backend)?;
@@ -420,8 +423,12 @@ mod tests {
420423
421424 // OrgName lowercases, so this can only arrive via from_trusted -- but the
422425 // constraint is what we're testing, not the value object.
423 let clash =
424 Organization::from_trusted(OrgId::generate(), OrgName::from_trusted("JAMES"), None);
426+ let clash = Organization::from_trusted(
427+ OrgId::generate(),
428+ OrgName::from_trusted("JAMES"),
429+ None,
430+ None,
431+ );
425432 let result = repos.orgs.save(&clash).await;
426433
427434 assert!(result.is_err(), "collate nocase should catch this");
src/infrastructure/web/api.rs+24 −0View file
@@ -39,3 +39,27 @@ async fn me(cx: &Cx) -> Result<Json<Me>> {
3939 handle: identity.handle.to_string(),
4040 }))
4141 }
42+
43+/// A profile, as JSON, for anyone.
44+///
45+/// Mirrors `PublicProfile` exactly. There is no email field here and there must never
46+/// be one — this is served to anonymous callers.
47+#[derive(Debug, Serialize)]
48+struct Profile {
49+ handle: String,
50+ label: String,
51+ bio: Option<String>,
52+ viewer_is_owner: bool,
53+}
54+
55+#[route(GET "/api/users/{handle}")]
56+async fn user(cx: &Cx) -> Result<Json<Profile>> {
57+ let profile = super::profile::profile_for(cx).await?;
58+
59+ Ok(Json(Profile {
60+ handle: profile.handle.to_string(),
61+ label: profile.label,
62+ bio: profile.bio,
63+ viewer_is_owner: profile.viewer_is_owner,
64+ }))
65+}
src/infrastructure/web/context.rs+12 −2View file
@@ -17,7 +17,9 @@ use topcoat::{
1717 use crate::{
1818 application::{Identity, describe_identity, is_claimed, resolve_actor},
1919 domain::{Actor, SessionTokenHash},
20 infrastructure::repository::{SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo},
20+ infrastructure::repository::{
21+ SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo,
22+ },
2123 };
2224
2325 /// The one-time token that authorises claiming an unclaimed instance.
@@ -31,7 +33,7 @@ pub fn pool(cx: &Cx) -> &SqlitePool {
3133
3234 /// Anything below the web layer failing is a 500 — the visitor can't act on it, and
3335 /// the detail belongs in the log rather than the page.
34fn server_error<E>(error: E) -> topcoat::Error
36+pub fn server_error<E>(error: E) -> topcoat::Error
3537 where
3638 E: std::error::Error + Send + Sync + 'static,
3739 {
@@ -39,6 +41,14 @@ where
3941 internal_server_error(error).into()
4042 }
4143
44+pub fn orgs(cx: &Cx) -> SqliteOrgRepo {
45+ SqliteOrgRepo::new(pool(cx).clone())
46+}
47+
48+pub fn memberships(cx: &Cx) -> SqliteMembershipRepo {
49+ SqliteMembershipRepo::new(pool(cx).clone())
50+}
51+
4252 /// Who is making this request.
4353 ///
4454 /// Resolves to [`Actor::Anonymous`] when there is no session, the session is unknown,
src/infrastructure/web/mod.rs+1 −0View file
@@ -4,5 +4,6 @@ pub mod api;
44 pub mod context;
55 pub mod layout;
66 pub mod pages;
7+pub mod profile;
78 pub mod session_cookie;
89 pub mod setup;
src/infrastructure/web/pages.rs+10 −19View file
@@ -7,33 +7,24 @@ use topcoat::{
77
88 use super::context::{claimed, identity};
99
10/// The home page.
10+/// The root.
1111 ///
12/// A placeholder until Milestone 2 makes `/{owner}` the real one, but enough to prove
13/// the identity flow end to end.
14///
15/// Reads through the same `describe_identity` use case that `/api/me` uses — which is
16/// the point of keeping it in the application layer rather than querying here.
12+/// Unclaimed, it sends you to `/auth/setup`. Claimed and signed in, it forwards to your
13+/// own profile — the profile is the product, so the root is a signpost rather than a
14+/// page. Claimed and signed out, it offers a way in; a multi-user instance will want
15+/// something better here, but there is nothing to index yet.
1716 #[page("/")]
1817 async fn home(cx: &Cx) -> Result {
1918 if !claimed(cx).await? {
2019 return Err(redirect("/auth/setup").into());
2120 }
2221
22+ if let Some(identity) = identity(cx).await? {
23+ return Err(redirect(&format!("/{}", identity.handle)).into());
24+ }
25+
2326 view! {
2427 <h1>"steid"</h1>
25 (match identity(cx).await? {
26 Some(identity) => view! {
27 <p>"Signed in as " <strong>(identity.handle.as_str())</strong></p>
28 <p>(identity.email.as_str())</p>
29 <form method="post" action="/auth/logout">
30 <button type="submit">"Sign out"</button>
31 </form>
32 },
33 None => view! {
34 <p>"Not signed in."</p>
35 <p><a href="/auth/login">"Sign in"</a></p>
36 },
37 }?)
28+ <p><a href="/auth/login">"Sign in"</a></p>
3829 }
3930 }
src/infrastructure/web/profile.rs+87 −0View file
@@ -0,0 +1,87 @@
1+//! The profile page — `/{handle}`.
2+//!
3+//! Public. This is the first page that renders for anonymous visitors, so everything it
4+//! shows comes from [`PublicProfile`](crate::application::PublicProfile), which has no
5+//! private fields to leak.
6+
7+use topcoat::{
8+ Result,
9+ context::Cx,
10+ router::{
11+ error::{RouterErrorExt, not_found},
12+ page, path_param,
13+ },
14+ view::view,
15+};
16+
17+use crate::{
18+ application::{PublicProfile, view_profile},
19+ domain::OrgName,
20+};
21+
22+use super::context::{current_actor, memberships, orgs, server_error};
23+
24+/// `{handle}` from the path. The struct name snake-cased is the parameter name.
25+///
26+/// Declared as `str` so the raw segment arrives unparsed — validation is `OrgName`'s
27+/// job, and a handle that fails it is a page that does not exist rather than a bad
28+/// request.
29+#[path_param]
30+struct Handle(str);
31+
32+/// Resolves `{handle}` from the path into a profile, or 404.
33+///
34+/// A malformed handle 404s rather than erroring: `/Not A Handle` is a page that does
35+/// not exist, not a bad request.
36+pub(super) async fn profile_for(cx: &Cx) -> Result<PublicProfile> {
37+ let raw = path_param::<Handle>(cx);
38+ let handle = OrgName::new(raw).map_err(|_| not_found())?;
39+ let actor = current_actor(cx).await?;
40+
41+ Ok(view_profile(&handle, &actor, &orgs(cx), &memberships(cx))
42+ .await
43+ .map_err(server_error)?
44+ .ok_or_not_found()?)
45+}
46+
47+#[page("/{handle}")]
48+async fn profile(cx: &Cx) -> Result {
49+ let profile = profile_for(cx).await?;
50+
51+ view! {
52+ <header>
53+ <h1>(&profile.label)</h1>
54+ <p><code>"@" (profile.handle.as_str())</code></p>
55+ ({
56+ match &profile.bio {
57+ Some(bio) => view! { <p>(bio)</p> },
58+ None => view! {},
59+ }
60+ }?)
61+ ({
62+ if profile.viewer_is_owner {
63+ view! {
64+ <p><a href=(format!("/{}/settings", profile.handle))>"Edit profile"</a></p>
65+ }
66+ } else {
67+ view! {}
68+ }
69+ }?)
70+ </header>
71+
72+ <section>
73+ <h2>"Repositories"</h2>
74+ <p>"Nothing here yet."</p>
75+ </section>
76+
77+ <section>
78+ <h2>"Writing"</h2>
79+ <p>"Nothing here yet."</p>
80+ </section>
81+
82+ <section>
83+ <h2>"Projects"</h2>
84+ <p>"Nothing here yet."</p>
85+ </section>
86+ }
87+}