fabrica

hanna/fabrica

feat(web): tabbed settings and multiple emails with a primary

7a9881b · hanna committed on 2026-07-25

Split account settings into Profile / Account / SSH & GPG keys / API tokens
tabs, each its own GET route with a sidebar nav that works without JS. Add
multiple email addresses per user (migration 0006 + user_emails table): add,
remove, and choose a primary (mirrored into users.email), plus a per-user
toggle to publish the primary email on the profile. create_user now seeds the
primary email row in a transaction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
11 files changed · +644 −58UnifiedSplit
assets/base.css +75 −0
@@ -595,6 +595,81 @@ body.drawer-open .drawer-backdrop {
595595 flex: none;
596596 }
597597
598+/* Settings: a left tab rail and the active tab's content. */
599+.settings-layout {
600+ display: flex;
601+ gap: 2rem;
602+ align-items: flex-start;
603+}
604+.settings-nav {
605+ flex: none;
606+ width: 13rem;
607+ position: sticky;
608+ top: calc(var(--fb-topbar-h) + 1rem);
609+}
610+.settings-nav h1 {
611+ font-size: 1.4rem;
612+ margin-bottom: 0.75rem;
613+}
614+.settings-nav nav {
615+ display: flex;
616+ flex-direction: column;
617+ gap: 0.15rem;
618+}
619+.settings-nav nav a {
620+ padding: 0.4rem 0.7rem;
621+ border-radius: var(--fb-radius);
622+ color: var(--fb-fg-muted);
623+}
624+.settings-nav nav a:hover {
625+ background: var(--fb-bg-inset);
626+ text-decoration: none;
627+}
628+.settings-nav nav a[aria-current="page"] {
629+ background: var(--fb-bg-inset);
630+ color: var(--fb-fg);
631+ font-weight: 600;
632+}
633+.settings-content {
634+ flex: 1;
635+ min-width: 0;
636+}
637+.settings-content .listing:first-child {
638+ margin-top: 0;
639+}
640+.email-list {
641+ margin-bottom: 1rem;
642+}
643+.email-visibility {
644+ margin: 0.5rem 0 1rem;
645+}
646+.email-add label {
647+ margin-top: 0;
648+}
649+.email-add-row {
650+ display: flex;
651+ gap: 0.5rem;
652+ align-items: stretch;
653+}
654+.email-add-row input {
655+ flex: 1;
656+ min-width: 12rem;
657+}
658+@media (max-width: 48rem) {
659+ .settings-layout {
660+ flex-direction: column;
661+ gap: 1rem;
662+ }
663+ .settings-nav {
664+ width: 100%;
665+ position: static;
666+ }
667+ .settings-nav nav {
668+ flex-direction: row;
669+ flex-wrap: wrap;
670+ }
671+}
672+
598673 label {
599674 display: block;
600675 margin: 0.75rem 0 0.25rem;
crates/model/src/entity.rs +20 −1
@@ -102,6 +102,22 @@ impl std::fmt::Display for Visibility {
102102 }
103103 }
104104
105+/// One of a user's email addresses. Exactly one per user is primary; the primary
106+/// is mirrored into [`User::email`].
107+#[derive(Debug, Clone, PartialEq, Eq)]
108+pub struct UserEmail {
109+ /// ULID primary key.
110+ pub id: String,
111+ /// Owning user.
112+ pub user_id: String,
113+ /// The address as entered.
114+ pub email: String,
115+ /// Whether this is the user's primary address.
116+ pub is_primary: bool,
117+ /// Creation time.
118+ pub created_at: Timestamp,
119+}
120+
105121 /// A registered account.
106122 #[derive(Debug, Clone, PartialEq, Eq)]
107123 pub struct User {
@@ -109,8 +125,11 @@ pub struct User {
109125 pub id: String,
110126 /// The name as the user typed it (case preserved for display).
111127 pub username: String,
112- /// The primary email address.
128+ /// The primary email address (denormalized from [`UserEmail`] for quick
129+ /// access and case-insensitive login/uniqueness).
113130 pub email: String,
131+ /// Whether the primary email is shown on the public profile.
132+ pub email_public: bool,
114133 /// Optional human-friendly display name.
115134 pub display_name: Option<String>,
116135 /// Optional pronouns, shown on the profile (e.g. `they/them`).
crates/model/src/lib.rs +1 −1
@@ -13,6 +13,6 @@ pub mod name;
1313
1414 pub use entity::{
1515 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,
16- Timestamp, User, Visibility,
16+ Timestamp, User, UserEmail, Visibility,
1717 };
1818 pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};
crates/store/src/users.rs +158 −3
@@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// The columns of `users` in a fixed order, shared by every `SELECT` so the
1414 /// row-mapping in [`Store::map_user`] can rely on names.
15-pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \
16- location, links, avatar_mime, password_hash, \
15+pub(crate) const USER_COLUMNS: &str = "id, username, email, email_public, display_name, pronouns, \
16+ bio, location, links, avatar_mime, password_hash, \
1717 must_change_password, is_admin, disabled_at, created_at, updated_at";
1818
1919 /// The editable profile fields, set together by the settings page. Each `None`
@@ -94,6 +94,7 @@ impl Store {
9494 id: new_id(),
9595 username: new.username,
9696 email: new.email,
97+ email_public: false,
9798 display_name: new.display_name,
9899 pronouns: None,
99100 bio: None,
@@ -108,6 +109,7 @@ impl Store {
108109 updated_at: now,
109110 };
110111
112+ let mut tx = self.pool.begin().await?;
111113 let sql = "INSERT INTO users \
112114 (id, username, username_lower, email, email_lower, display_name, \
113115 password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \
@@ -125,11 +127,26 @@ impl Store {
125127 .bind(user.disabled_at)
126128 .bind(user.created_at)
127129 .bind(user.updated_at)
128- .execute(&self.pool)
130+ .execute(&mut *tx)
129131 .await
130132 .map_err(|e| {
131133 map_conflict(e, "user", &[("email", "email"), ("username", "username")])
132134 })?;
135+ // Seed the primary email row so `user_emails` holds every address.
136+ sqlx::query(
137+ "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \
138+ VALUES ($1, $2, $3, $4, $5, $6)",
139+ )
140+ .bind(new_id())
141+ .bind(user.id.clone())
142+ .bind(user.email.clone())
143+ .bind(user.email.to_lowercase())
144+ .bind(true)
145+ .bind(user.created_at)
146+ .execute(&mut *tx)
147+ .await
148+ .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?;
149+ tx.commit().await?;
133150
134151 Ok(user)
135152 }
@@ -226,6 +243,143 @@ impl Store {
226243 Ok(updated > 0)
227244 }
228245
246+ /// List a user's email addresses, primary first.
247+ ///
248+ /// # Errors
249+ ///
250+ /// Returns [`StoreError::Query`] on a database failure.
251+ pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> {
252+ let rows = sqlx::query(
253+ "SELECT id, user_id, email, is_primary, created_at FROM user_emails \
254+ WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC",
255+ )
256+ .bind(user_id)
257+ .fetch_all(&self.pool)
258+ .await?;
259+ rows.iter()
260+ .map(|r| {
261+ Ok(model::UserEmail {
262+ id: r.try_get("id")?,
263+ user_id: r.try_get("user_id")?,
264+ email: r.try_get("email")?,
265+ is_primary: self.get_bool(r, "is_primary")?,
266+ created_at: r.try_get("created_at")?,
267+ })
268+ })
269+ .collect::<Result<_, sqlx::Error>>()
270+ .map_err(Into::into)
271+ }
272+
273+ /// Add a secondary email address (case-insensitively unique across all users).
274+ ///
275+ /// # Errors
276+ ///
277+ /// * [`StoreError::Conflict`] if the address is already registered.
278+ /// * [`StoreError::Query`] for any other database failure.
279+ pub async fn add_email(
280+ &self,
281+ user_id: &str,
282+ email: &str,
283+ ) -> Result<model::UserEmail, StoreError> {
284+ let row = model::UserEmail {
285+ id: new_id(),
286+ user_id: user_id.to_string(),
287+ email: email.to_string(),
288+ is_primary: false,
289+ created_at: now_ms(),
290+ };
291+ sqlx::query(
292+ "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \
293+ VALUES ($1, $2, $3, $4, $5, $6)",
294+ )
295+ .bind(&row.id)
296+ .bind(&row.user_id)
297+ .bind(&row.email)
298+ .bind(email.to_lowercase())
299+ .bind(false)
300+ .bind(row.created_at)
301+ .execute(&self.pool)
302+ .await
303+ .map_err(|e| map_conflict(e, "email", &[("email_lower", "email")]))?;
304+ Ok(row)
305+ }
306+
307+ /// Remove one of a user's non-primary email addresses.
308+ ///
309+ /// # Errors
310+ ///
311+ /// Returns [`StoreError::Query`] on a database failure.
312+ pub async fn delete_email(&self, user_id: &str, email_id: &str) -> Result<bool, StoreError> {
313+ let deleted = sqlx::query(
314+ "DELETE FROM user_emails WHERE id = $1 AND user_id = $2 AND is_primary = $3",
315+ )
316+ .bind(email_id)
317+ .bind(user_id)
318+ .bind(false)
319+ .execute(&self.pool)
320+ .await?
321+ .rows_affected();
322+ Ok(deleted > 0)
323+ }
324+
325+ /// Make one of a user's addresses primary, mirroring it into `users.email`.
326+ ///
327+ /// # Errors
328+ ///
329+ /// Returns [`StoreError::Query`] on a database failure.
330+ pub async fn set_primary_email(
331+ &self,
332+ user_id: &str,
333+ email_id: &str,
334+ ) -> Result<bool, StoreError> {
335+ // Confirm the address belongs to the user and capture its value.
336+ let row = sqlx::query("SELECT email FROM user_emails WHERE id = $1 AND user_id = $2")
337+ .bind(email_id)
338+ .bind(user_id)
339+ .fetch_optional(&self.pool)
340+ .await?;
341+ let Some(row) = row else {
342+ return Ok(false);
343+ };
344+ let email: String = row.try_get("email")?;
345+
346+ let mut tx = self.pool.begin().await?;
347+ sqlx::query("UPDATE user_emails SET is_primary = $1 WHERE user_id = $2")
348+ .bind(false)
349+ .bind(user_id)
350+ .execute(&mut *tx)
351+ .await?;
352+ sqlx::query("UPDATE user_emails SET is_primary = $1 WHERE id = $2")
353+ .bind(true)
354+ .bind(email_id)
355+ .execute(&mut *tx)
356+ .await?;
357+ sqlx::query("UPDATE users SET email = $1, email_lower = $2, updated_at = $3 WHERE id = $4")
358+ .bind(&email)
359+ .bind(email.to_lowercase())
360+ .bind(now_ms())
361+ .bind(user_id)
362+ .execute(&mut *tx)
363+ .await?;
364+ tx.commit().await?;
365+ Ok(true)
366+ }
367+
368+ /// Set whether the user's primary email is shown on their public profile.
369+ ///
370+ /// # Errors
371+ ///
372+ /// Returns [`StoreError::Query`] on a database failure.
373+ pub async fn set_email_public(&self, user_id: &str, public: bool) -> Result<(), StoreError> {
374+ sqlx::query("UPDATE users SET email_public = $1, updated_at = $2 WHERE id = $3")
375+ .bind(public)
376+ .bind(now_ms())
377+ .bind(user_id)
378+ .execute(&self.pool)
379+ .await?;
380+ Ok(())
381+ }
382+
229383 /// Set (or clear) a user's password hash.
230384 ///
231385 /// Passing `Some(hash)` sets the credential and clears the
@@ -374,6 +528,7 @@ impl Store {
374528 id: row.try_get("id")?,
375529 username: row.try_get("username")?,
376530 email: row.try_get("email")?,
531+ email_public: self.get_bool(row, "email_public")?,
377532 display_name: row.try_get("display_name")?,
378533 pronouns: row.try_get("pronouns")?,
379534 bio: row.try_get("bio")?,
crates/web/src/icons.rs +4 −0
@@ -33,6 +33,7 @@ pub enum Icon {
3333 Dashboard,
3434 Compass,
3535 MapPin,
36+ Mail,
3637 Globe,
3738 Download,
3839 Github,
@@ -100,6 +101,9 @@ impl Icon {
100101 Icon::MapPin => {
101102 r#"<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>"#
102103 }
104+ Icon::Mail => {
105+ r#"<rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>"#
106+ }
103107 Icon::Globe => {
104108 r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#
105109 }
crates/web/src/lib.rs +13 −3
@@ -152,12 +152,22 @@ pub fn build_router(state: AppState) -> Router {
152152 "/settings",
153153 get(pages::settings).post(pages::settings_submit),
154154 )
155+ .route("/settings/account", get(pages::settings_account))
155156 .route("/settings/username", post(pages::account_username))
156- .route("/settings/email", post(pages::account_email))
157+ .route("/settings/emails", post(pages::email_add))
158+ .route("/settings/emails/primary", post(pages::email_primary))
159+ .route("/settings/emails/delete", post(pages::email_delete))
160+ .route("/settings/emails/visibility", post(pages::email_visibility))
157161 .route("/settings/password", post(pages::account_password))
158- .route("/settings/tokens", post(pages::token_create))
162+ .route(
163+ "/settings/tokens",
164+ get(pages::settings_tokens).post(pages::token_create),
165+ )
159166 .route("/settings/tokens/revoke", post(pages::token_revoke))
160- .route("/settings/keys", post(pages::key_add))
167+ .route(
168+ "/settings/keys",
169+ get(pages::settings_keys).post(pages::key_add),
170+ )
161171 .route("/settings/keys/delete", post(pages::key_delete))
162172 .route("/settings/avatar", post(avatar::upload))
163173 .route("/settings/theme", get(pages::set_theme))
crates/web/src/pages.rs +232 −38
@@ -919,17 +919,54 @@ async fn live_invite(state: &AppState, token: &str) -> AppResult<model::Invite>
919919 Ok(invite)
920920 }
921921
922-/// `GET /settings` — the viewer's profile editor.
922+/// `GET /settings` — the Profile tab.
923923 pub async fn settings(
924924 State(state): State<AppState>,
925925 RequireUser(user): RequireUser,
926926 jar: CookieJar,
927927 uri: Uri,
928928 ) -> AppResult<Response> {
929- let tokens = state.store.tokens_by_user(&user.id).await?;
929+ let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
930+ let body = settings_shell("profile", profile_tab(&user, &chrome.csrf));
931+ Ok((jar, page(&chrome, "Settings", body)).into_response())
932+}
933+
934+/// `GET /settings/account` — the Account tab (username, emails, password).
935+pub async fn settings_account(
936+ State(state): State<AppState>,
937+ RequireUser(user): RequireUser,
938+ jar: CookieJar,
939+ uri: Uri,
940+) -> AppResult<Response> {
941+ let emails = state.store.emails_by_user(&user.id).await?;
942+ let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
943+ let body = settings_shell("account", account_tab(&user, &chrome.csrf, &emails));
944+ Ok((jar, page(&chrome, "Settings", body)).into_response())
945+}
946+
947+/// `GET /settings/keys` — the SSH and GPG keys tab.
948+pub async fn settings_keys(
949+ State(state): State<AppState>,
950+ RequireUser(user): RequireUser,
951+ jar: CookieJar,
952+ uri: Uri,
953+) -> AppResult<Response> {
930954 let keys = state.store.keys_by_user(&user.id).await?;
931955 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
932- let body = settings_body(&user, &chrome.csrf, &tokens, &keys, None);
956+ let body = settings_shell("keys", keys_section(&chrome.csrf, &keys));
957+ Ok((jar, page(&chrome, "Settings", body)).into_response())
958+}
959+
960+/// `GET /settings/tokens` — the API tokens tab.
961+pub async fn settings_tokens(
962+ State(state): State<AppState>,
963+ RequireUser(user): RequireUser,
964+ jar: CookieJar,
965+ uri: Uri,
966+) -> AppResult<Response> {
967+ let tokens = state.store.tokens_by_user(&user.id).await?;
968+ let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
969+ let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, None));
933970 Ok((jar, page(&chrome, "Settings", body)).into_response())
934971 }
935972
@@ -1010,15 +1047,12 @@ fn norm(s: &str) -> Option<String> {
10101047 (!t.is_empty()).then(|| t.to_string())
10111048 }
10121049
1013-/// A single-field account form (username or email) with a CSRF token.
1050+/// The change-username form.
10141051 #[derive(Debug, Deserialize)]
10151052 pub struct AccountFieldForm {
10161053 /// The new username.
10171054 #[serde(default)]
10181055 username: String,
1019- /// The new email.
1020- #[serde(default)]
1021- email: String,
10221056 /// CSRF token.
10231057 #[serde(rename = "_csrf")]
10241058 csrf: String,
@@ -1040,7 +1074,7 @@ pub async fn account_username(
10401074 .update_username(&user.id, form.username.trim())
10411075 .await
10421076 {
1043- Ok(_) => Redirect::to("/settings").into_response(),
1077+ Ok(_) => Redirect::to("/settings/account").into_response(),
10441078 Err(store::StoreError::Conflict { .. }) => {
10451079 AppError::BadRequest("That username is already taken.".to_string()).into_response()
10461080 }
@@ -1051,13 +1085,24 @@ pub async fn account_username(
10511085 }
10521086 }
10531087
1054-/// `POST /settings/email` — change the viewer's email.
1055-pub async fn account_email(
1088+/// The add-email form.
1089+#[derive(Debug, Deserialize)]
1090+pub struct EmailAddForm {
1091+ /// The new address.
1092+ #[serde(default)]
1093+ email: String,
1094+ /// CSRF token.
1095+ #[serde(rename = "_csrf")]
1096+ csrf: String,
1097+}
1098+
1099+/// `POST /settings/emails` — add a secondary email address.
1100+pub async fn email_add(
10561101 State(state): State<AppState>,
10571102 RequireUser(user): RequireUser,
10581103 jar: CookieJar,
10591104 headers: HeaderMap,
1060- Form(form): Form<AccountFieldForm>,
1105+ Form(form): Form<EmailAddForm>,
10611106 ) -> Response {
10621107 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
10631108 return AppError::Forbidden.into_response();
@@ -1066,8 +1111,8 @@ pub async fn account_email(
10661111 if !email.contains('@') {
10671112 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();
10681113 }
1069- match state.store.update_email(&user.id, email).await {
1070- Ok(_) => Redirect::to("/settings").into_response(),
1114+ match state.store.add_email(&user.id, email).await {
1115+ Ok(_) => Redirect::to("/settings/account").into_response(),
10711116 Err(store::StoreError::Conflict { .. }) => {
10721117 AppError::BadRequest("That email is already in use.".to_string()).into_response()
10731118 }
@@ -1075,6 +1120,76 @@ pub async fn account_email(
10751120 }
10761121 }
10771122
1123+/// An email-id form (set-primary or delete).
1124+#[derive(Debug, Deserialize)]
1125+pub struct EmailIdForm {
1126+ /// The `user_emails` row id.
1127+ #[serde(default)]
1128+ id: String,
1129+ /// CSRF token.
1130+ #[serde(rename = "_csrf")]
1131+ csrf: String,
1132+}
1133+
1134+/// `POST /settings/emails/primary` — make one of the viewer's emails primary.
1135+pub async fn email_primary(
1136+ State(state): State<AppState>,
1137+ RequireUser(user): RequireUser,
1138+ jar: CookieJar,
1139+ headers: HeaderMap,
1140+ Form(form): Form<EmailIdForm>,
1141+) -> Response {
1142+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1143+ return AppError::Forbidden.into_response();
1144+ }
1145+ let _ = state.store.set_primary_email(&user.id, &form.id).await;
1146+ Redirect::to("/settings/account").into_response()
1147+}
1148+
1149+/// `POST /settings/emails/delete` — remove one of the viewer's secondary emails.
1150+pub async fn email_delete(
1151+ State(state): State<AppState>,
1152+ RequireUser(user): RequireUser,
1153+ jar: CookieJar,
1154+ headers: HeaderMap,
1155+ Form(form): Form<EmailIdForm>,
1156+) -> Response {
1157+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1158+ return AppError::Forbidden.into_response();
1159+ }
1160+ let _ = state.store.delete_email(&user.id, &form.id).await;
1161+ Redirect::to("/settings/account").into_response()
1162+}
1163+
1164+/// The email-visibility toggle form.
1165+#[derive(Debug, Deserialize)]
1166+pub struct EmailVisibilityForm {
1167+ /// Present (any value) when the checkbox is ticked.
1168+ #[serde(default)]
1169+ public: Option<String>,
1170+ /// CSRF token.
1171+ #[serde(rename = "_csrf")]
1172+ csrf: String,
1173+}
1174+
1175+/// `POST /settings/emails/visibility` — show/hide the primary email on the profile.
1176+pub async fn email_visibility(
1177+ State(state): State<AppState>,
1178+ RequireUser(user): RequireUser,
1179+ jar: CookieJar,
1180+ headers: HeaderMap,
1181+ Form(form): Form<EmailVisibilityForm>,
1182+) -> Response {
1183+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1184+ return AppError::Forbidden.into_response();
1185+ }
1186+ let _ = state
1187+ .store
1188+ .set_email_public(&user.id, form.public.is_some())
1189+ .await;
1190+ Redirect::to("/settings/account").into_response()
1191+}
1192+
10781193 /// The change-password form.
10791194 #[derive(Debug, Deserialize)]
10801195 pub struct PasswordForm {
@@ -1127,7 +1242,7 @@ pub async fn account_password(
11271242 }
11281243 .await;
11291244 match result {
1130- Ok(jar) => (jar, Redirect::to("/settings")).into_response(),
1245+ Ok(jar) => (jar, Redirect::to("/settings/account")).into_response(),
11311246 Err(err) => err.into_response(),
11321247 }
11331248 }
@@ -1216,10 +1331,13 @@ pub async fn token_create(
12161331 .tokens_by_user(&user.id)
12171332 .await
12181333 .unwrap_or_default();
1219- let keys = state.store.keys_by_user(&user.id).await.unwrap_or_default();
1220- let (jar, chrome) =
1221- build_chrome(&state, jar, Some(user.clone()), "/settings".to_string());
1222- let body = settings_body(&user, &chrome.csrf, &tokens, &keys, Some(&jwt));
1334+ let (jar, chrome) = build_chrome(
1335+ &state,
1336+ jar,
1337+ Some(user.clone()),
1338+ "/settings/tokens".to_string(),
1339+ );
1340+ let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, Some(&jwt)));
12231341 (jar, page(&chrome, "Settings", body)).into_response()
12241342 }
12251343 Err(err) => err.into_response(),
@@ -1259,7 +1377,7 @@ pub async fn token_revoke(
12591377 if owned {
12601378 let _ = state.store.revoke_token(&form.id).await;
12611379 }
1262- Redirect::to("/settings").into_response()
1380+ Redirect::to("/settings/tokens").into_response()
12631381 }
12641382
12651383 /// The add-key form.
@@ -1326,7 +1444,7 @@ pub async fn key_add(
13261444 })
13271445 .await
13281446 {
1329- Ok(_) => Redirect::to("/settings").into_response(),
1447+ Ok(_) => Redirect::to("/settings/keys").into_response(),
13301448 Err(store::StoreError::Conflict { .. }) => {
13311449 AppError::BadRequest("That key is already registered.".to_string()).into_response()
13321450 }
@@ -1365,20 +1483,40 @@ pub async fn key_delete(
13651483 if owned {
13661484 let _ = state.store.delete_key(&form.id).await;
13671485 }
1368- Redirect::to("/settings").into_response()
1486+ Redirect::to("/settings/keys").into_response()
13691487 }
13701488
13711489 /// Render the settings page: avatar, profile, account credentials, and tokens.
1372-fn settings_body(
1373- user: &User,
1374- csrf: &str,
1375- tokens: &[model::ApiToken],
1376- keys: &[model::Key],
1377- new_token: Option<&str>,
1378-) -> Markup {
1490+/// The settings page shell: a left tab nav and the active tab's content. Each tab
1491+/// is its own route, so navigation works without JavaScript.
1492+#[allow(clippy::needless_pass_by_value)] // `content` is an owned fragment embedded once.
1493+fn settings_shell(active: &str, content: Markup) -> Markup {
1494+ let tab = |href: &str, key: &str, label: &str| {
1495+ let current = active == key;
1496+ html! {
1497+ a href=(href) aria-current=[current.then_some("page")] { (label) }
1498+ }
1499+ };
1500+ html! {
1501+ div class="settings-layout" {
1502+ aside class="settings-nav" {
1503+ h1 { "Settings" }
1504+ nav {
1505+ (tab("/settings", "profile", "Profile"))
1506+ (tab("/settings/account", "account", "Account"))
1507+ (tab("/settings/keys", "keys", "SSH and GPG keys"))
1508+ (tab("/settings/tokens", "tokens", "API tokens"))
1509+ }
1510+ }
1511+ div class="settings-content" { (content) }
1512+ }
1513+ }
1514+}
1515+
1516+/// The Profile tab: avatar and profile fields.
1517+fn profile_tab(user: &User, csrf: &str) -> Markup {
13791518 let val = |v: &Option<String>| v.clone().unwrap_or_default();
13801519 html! {
1381- h1 { "Settings" }
13821520 section class="listing" {
13831521 h2 { "Avatar" }
13841522 div class="card avatar-settings" {
@@ -1422,8 +1560,14 @@ fn settings_body(
14221560 }
14231561 }
14241562 }
1563+ }
1564+}
1565+
1566+/// The Account tab: username, email(s), and password.
1567+fn account_tab(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup {
1568+ html! {
14251569 section class="listing" {
1426- h2 { "Account" }
1570+ h2 { "Username" }
14271571 div class="card" {
14281572 form method="post" action="/settings/username" {
14291573 input type="hidden" name="_csrf" value=(csrf);
@@ -1431,17 +1575,12 @@ fn settings_body(
14311575 input type="text" id="username" name="username" value=(user.username) required;
14321576 button class="btn" type="submit" { "Change username" }
14331577 }
1434- form method="post" action="/settings/email" {
1435- input type="hidden" name="_csrf" value=(csrf);
1436- label for="email" { "Email" }
1437- input type="email" id="email" name="email" value=(user.email) required;
1438- button class="btn" type="submit" { "Change email" }
1439- }
14401578 p class="muted field-hint" {
14411579 "Administrator: " (if user.is_admin { "yes" } else { "no" })
14421580 }
14431581 }
14441582 }
1583+ (emails_section(user, csrf, emails))
14451584 section class="listing" {
14461585 h2 { "Password" }
14471586 div class="card" {
@@ -1460,8 +1599,63 @@ fn settings_body(
14601599 }
14611600 }
14621601 }
1463- (keys_section(csrf, keys))
1464- (tokens_section(csrf, tokens, new_token))
1602+ }
1603+}
1604+
1605+/// The emails sub-section of the Account tab: the list of addresses (primary
1606+/// marked, with set-primary / remove actions), the profile visibility toggle,
1607+/// and the add-email form.
1608+fn emails_section(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup {
1609+ html! {
1610+ section class="listing" {
1611+ h2 { "Emails" }
1612+ div class="card" {
1613+ ul class="entry-list email-list" {
1614+ @for e in emails {
1615+ li class="entry-row" {
1616+ div class="entry-row-body" {
1617+ span class="entry-row-title" {
1618+ (e.email)
1619+ @if e.is_primary { " " span class="badge" { "primary" } }
1620+ }
1621+ }
1622+ div class="entry-row-actions" {
1623+ @if !e.is_primary {
1624+ form method="post" action="/settings/emails/primary" {
1625+ input type="hidden" name="_csrf" value=(csrf);
1626+ input type="hidden" name="id" value=(e.id);
1627+ button class="btn" type="submit" { "Make primary" }
1628+ }
1629+ form method="post" action="/settings/emails/delete" {
1630+ input type="hidden" name="_csrf" value=(csrf);
1631+ input type="hidden" name="id" value=(e.id);
1632+ button class="btn btn-danger" type="submit" { "Remove" }
1633+ }
1634+ }
1635+ }
1636+ }
1637+ }
1638+ }
1639+ form class="email-visibility" method="post" action="/settings/emails/visibility" {
1640+ input type="hidden" name="_csrf" value=(csrf);
1641+ label class="checkbox" {
1642+ input type="checkbox" name="public" value="1" checked[user.email_public]
1643+ onchange="this.form.submit()";
1644+ " Show my primary email on my profile"
1645+ }
1646+ noscript { button class="btn" type="submit" { "Save" } }
1647+ }
1648+ form class="email-add" method="post" action="/settings/emails" {
1649+ input type="hidden" name="_csrf" value=(csrf);
1650+ label for="new_email" { "Add email address" }
1651+ div class="email-add-row" {
1652+ input type="email" id="new_email" name="email" required
1653+ placeholder="you@example.com";
1654+ button class="btn btn-primary inline-btn" type="submit" { "Add" }
1655+ }
1656+ }
1657+ }
1658+ }
14651659 }
14661660 }
14671661
crates/web/src/repo.rs +10 −0
@@ -1965,6 +1965,16 @@ fn profile_links(owner: &User) -> Vec<Markup> {
19651965 if let Some(location) = non_empty(owner.location.as_ref()) {
19661966 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
19671967 }
1968+ // The primary email, only when the owner has chosen to publish it.
1969+ if owner.email_public {
1970+ if let Some(email) = non_empty(Some(&owner.email)) {
1971+ out.push(html! {
1972+ li {
1973+ a href=(format!("mailto:{email}")) { (icon(Icon::Mail)) span { (email) } }
1974+ }
1975+ });
1976+ }
1977+ }
19681978 for link in &owner.links {
19691979 let href = normalize_url(link);
19701980 let shown = href
crates/web/src/tests.rs +95 −12
@@ -452,20 +452,103 @@ async fn new_repo_page_requires_auth_and_renders() {
452452 }
453453
454454 #[tokio::test]
455-async fn settings_page_shows_account_and_tokens() {
455+async fn settings_tabs_render() {
456456 let (_state, app) = app().await;
457457 let cookie = login(&app).await;
458- let req = Request::builder()
459- .uri("/settings")
460- .header(header::COOKIE, cookie)
461- .body(Body::empty())
458+ let get_tab = |uri: &'static str, cookie: String| {
459+ let app = app.clone();
460+ async move {
461+ let req = Request::builder()
462+ .uri(uri)
463+ .header(header::COOKIE, cookie)
464+ .body(Body::empty())
465+ .unwrap();
466+ let res = app.oneshot(req).await.unwrap();
467+ assert_eq!(res.status(), StatusCode::OK, "{uri}");
468+ body_string(res).await
469+ }
470+ };
471+ // The Profile tab shows the tab nav.
472+ let profile = get_tab("/settings", cookie.clone()).await;
473+ assert!(profile.contains("Save profile"), "profile tab");
474+ assert!(profile.contains("/settings/account"), "tab nav present");
475+ // The Account tab shows username, emails, and password.
476+ let account = get_tab("/settings/account", cookie.clone()).await;
477+ assert!(account.contains("Change username"), "username");
478+ assert!(account.contains("Emails"), "emails section");
479+ assert!(account.contains("Change password"), "password");
480+ // The tokens tab.
481+ let tokens = get_tab("/settings/tokens", cookie).await;
482+ assert!(tokens.contains("API tokens"), "tokens tab");
483+}
484+
485+#[tokio::test]
486+async fn emails_add_set_primary_and_publish_on_profile() {
487+ let (state, app) = app().await;
488+ let cookie = login(&app).await;
489+ let token = cookie
490+ .split("fabrica_csrf=")
491+ .nth(1)
492+ .and_then(|s| s.split(';').next())
493+ .unwrap()
494+ .to_string();
495+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
496+
497+ // Add a second address.
498+ let post = |uri: &'static str, body: String, cookie: String| {
499+ let app = app.clone();
500+ async move {
501+ let req = Request::builder()
502+ .method("POST")
503+ .uri(uri)
504+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
505+ .header(header::COOKIE, cookie)
506+ .body(Body::from(body))
507+ .unwrap();
508+ app.oneshot(req).await.unwrap()
509+ }
510+ };
511+ let res = post(
512+ "/settings/emails",
513+ format!("email=second@example.com&_csrf={token}"),
514+ cookie.clone(),
515+ )
516+ .await;
517+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
518+ let emails = state.store.emails_by_user(&ada.id).await.unwrap();
519+ assert_eq!(emails.len(), 2);
520+ let second = emails
521+ .iter()
522+ .find(|e| e.email == "second@example.com")
462523 .unwrap();
463- let res = app.oneshot(req).await.unwrap();
464- assert_eq!(res.status(), StatusCode::OK);
524+ assert!(!second.is_primary);
525+ let second_id = second.id.clone();
526+
527+ // Make it primary; users.email mirrors it.
528+ let res = post(
529+ "/settings/emails/primary",
530+ format!("id={second_id}&_csrf={token}"),
531+ cookie.clone(),
532+ )
533+ .await;
534+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
535+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
536+ assert_eq!(ada.email, "second@example.com");
537+
538+ // Publish it and confirm it appears on the profile.
539+ let res = post(
540+ "/settings/emails/visibility",
541+ format!("public=1&_csrf={token}"),
542+ cookie,
543+ )
544+ .await;
545+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
546+ let res = app.oneshot(get("/ada")).await.unwrap();
465547 let html = body_string(res).await;
466- assert!(html.contains("Change username"), "account section");
467- assert!(html.contains("Change password"), "password section");
468- assert!(html.contains("API tokens"), "tokens section");
548+ assert!(
549+ html.contains("second@example.com"),
550+ "email shown on profile"
551+ );
469552 }
470553
471554 /// An app with self-registration enabled (and no seeded user).
@@ -853,7 +936,7 @@ async fn settings_keys_add_list_and_delete() {
853936
854937 // The settings page shows the keys section.
855938 let req = Request::builder()
856- .uri("/settings")
939+ .uri("/settings/keys")
857940 .header(header::COOKIE, cookie.clone())
858941 .body(Body::empty())
859942 .unwrap();
@@ -883,7 +966,7 @@ async fn settings_keys_add_list_and_delete() {
883966
884967 // It renders on the settings page.
885968 let req = Request::builder()
886- .uri("/settings")
969+ .uri("/settings/keys")
887970 .header(header::COOKIE, cookie.clone())
888971 .body(Body::empty())
889972 .unwrap();
migrations/postgres/0006_user_emails.sql +18 −0
@@ -0,0 +1,18 @@
1+-- Multiple email addresses per user, one marked primary, plus a per-user toggle
2+-- to show the primary email on the public profile.
3+ALTER TABLE users ADD COLUMN email_public BOOLEAN NOT NULL DEFAULT FALSE;
4+
5+CREATE TABLE user_emails (
6+ id TEXT PRIMARY KEY,
7+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+ email TEXT NOT NULL,
9+ email_lower TEXT NOT NULL,
10+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE UNIQUE INDEX idx_user_emails_lower ON user_emails (email_lower);
14+CREATE INDEX idx_user_emails_user ON user_emails (user_id);
15+
16+-- Seed each existing user's current email as their primary address.
17+INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at)
18+SELECT id, id, email, email_lower, TRUE, created_at FROM users;
migrations/sqlite/0006_user_emails.sql +18 −0
@@ -0,0 +1,18 @@
1+-- Multiple email addresses per user, one marked primary, plus a per-user toggle
2+-- to show the primary email on the public profile.
3+ALTER TABLE users ADD COLUMN email_public INTEGER NOT NULL DEFAULT 0;
4+
5+CREATE TABLE user_emails (
6+ id TEXT PRIMARY KEY,
7+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+ email TEXT NOT NULL,
9+ email_lower TEXT NOT NULL,
10+ is_primary INTEGER NOT NULL DEFAULT 0,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE UNIQUE INDEX idx_user_emails_lower ON user_emails (email_lower);
14+CREATE INDEX idx_user_emails_user ON user_emails (user_id);
15+
16+-- Seed each existing user's current email as their primary address.
17+INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at)
18+SELECT id, id, email, email_lower, 1, created_at FROM users;