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 {
595 flex: none;595 flex: none;
596}596}
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
598label {673label {
599 display: block;674 display: block;
600 margin: 0.75rem 0 0.25rem;675 margin: 0.75rem 0 0.25rem;
crates/model/src/entity.rs +20 −1
@@ -102,6 +102,22 @@ impl std::fmt::Display for Visibility {
102 }102 }
103}103}
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)]
108pub 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
105/// A registered account.121/// A registered account.
106#[derive(Debug, Clone, PartialEq, Eq)]122#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct User {123pub struct User {
@@ -109,8 +125,11 @@ pub struct User {
109 pub id: String,125 pub id: String,
110 /// The name as the user typed it (case preserved for display).126 /// The name as the user typed it (case preserved for display).
111 pub username: String,127 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).
113 pub email: String,130 pub email: String,
131 /// Whether the primary email is shown on the public profile.
132 pub email_public: bool,
114 /// Optional human-friendly display name.133 /// Optional human-friendly display name.
115 pub display_name: Option<String>,134 pub display_name: Option<String>,
116 /// Optional pronouns, shown on the profile (e.g. `they/them`).135 /// 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
14pub use entity::{14pub use entity::{
15 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,15 ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo,
16 Timestamp, User, Visibility,16 Timestamp, User, UserEmail, Visibility,
17};17};
18pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name};18pub 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
13/// The columns of `users` in a fixed order, shared by every `SELECT` so the13/// The columns of `users` in a fixed order, shared by every `SELECT` so the
14/// row-mapping in [`Store::map_user`] can rely on names.14/// row-mapping in [`Store::map_user`] can rely on names.
15pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \15pub(crate) const USER_COLUMNS: &str = "id, username, email, email_public, display_name, pronouns, \
16 location, links, avatar_mime, password_hash, \16 bio, location, links, avatar_mime, password_hash, \
17 must_change_password, is_admin, disabled_at, created_at, updated_at";17 must_change_password, is_admin, disabled_at, created_at, updated_at";
1818
19/// The editable profile fields, set together by the settings page. Each `None`19/// The editable profile fields, set together by the settings page. Each `None`
@@ -94,6 +94,7 @@ impl Store {
94 id: new_id(),94 id: new_id(),
95 username: new.username,95 username: new.username,
96 email: new.email,96 email: new.email,
97 email_public: false,
97 display_name: new.display_name,98 display_name: new.display_name,
98 pronouns: None,99 pronouns: None,
99 bio: None,100 bio: None,
@@ -108,6 +109,7 @@ impl Store {
108 updated_at: now,109 updated_at: now,
109 };110 };
110111
112 let mut tx = self.pool.begin().await?;
111 let sql = "INSERT INTO users \113 let sql = "INSERT INTO users \
112 (id, username, username_lower, email, email_lower, display_name, \114 (id, username, username_lower, email, email_lower, display_name, \
113 password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \115 password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \
@@ -125,11 +127,26 @@ impl Store {
125 .bind(user.disabled_at)127 .bind(user.disabled_at)
126 .bind(user.created_at)128 .bind(user.created_at)
127 .bind(user.updated_at)129 .bind(user.updated_at)
128 .execute(&self.pool)130 .execute(&mut *tx)
129 .await131 .await
130 .map_err(|e| {132 .map_err(|e| {
131 map_conflict(e, "user", &[("email", "email"), ("username", "username")])133 map_conflict(e, "user", &[("email", "email"), ("username", "username")])
132 })?;134 })?;
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
134 Ok(user)151 Ok(user)
135 }152 }
@@ -226,6 +243,143 @@ impl Store {
226 Ok(updated > 0)243 Ok(updated > 0)
227 }244 }
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
229 /// Set (or clear) a user's password hash.383 /// Set (or clear) a user's password hash.
230 ///384 ///
231 /// Passing `Some(hash)` sets the credential and clears the385 /// Passing `Some(hash)` sets the credential and clears the
@@ -374,6 +528,7 @@ impl Store {
374 id: row.try_get("id")?,528 id: row.try_get("id")?,
375 username: row.try_get("username")?,529 username: row.try_get("username")?,
376 email: row.try_get("email")?,530 email: row.try_get("email")?,
531 email_public: self.get_bool(row, "email_public")?,
377 display_name: row.try_get("display_name")?,532 display_name: row.try_get("display_name")?,
378 pronouns: row.try_get("pronouns")?,533 pronouns: row.try_get("pronouns")?,
379 bio: row.try_get("bio")?,534 bio: row.try_get("bio")?,
crates/web/src/icons.rs +4 −0
@@ -33,6 +33,7 @@ pub enum Icon {
33 Dashboard,33 Dashboard,
34 Compass,34 Compass,
35 MapPin,35 MapPin,
36 Mail,
36 Globe,37 Globe,
37 Download,38 Download,
38 Github,39 Github,
@@ -100,6 +101,9 @@ impl Icon {
100 Icon::MapPin => {101 Icon::MapPin => {
101 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"/>"#102 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"/>"#
102 }103 }
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 }
103 Icon::Globe => {107 Icon::Globe => {
104 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"/>"#108 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"/>"#
105 }109 }
crates/web/src/lib.rs +13 −3
@@ -152,12 +152,22 @@ pub fn build_router(state: AppState) -> Router {
152 "/settings",152 "/settings",
153 get(pages::settings).post(pages::settings_submit),153 get(pages::settings).post(pages::settings_submit),
154 )154 )
155 .route("/settings/account", get(pages::settings_account))
155 .route("/settings/username", post(pages::account_username))156 .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))
157 .route("/settings/password", post(pages::account_password))161 .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 )
159 .route("/settings/tokens/revoke", post(pages::token_revoke))166 .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 )
161 .route("/settings/keys/delete", post(pages::key_delete))171 .route("/settings/keys/delete", post(pages::key_delete))
162 .route("/settings/avatar", post(avatar::upload))172 .route("/settings/avatar", post(avatar::upload))
163 .route("/settings/theme", get(pages::set_theme))173 .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>
919 Ok(invite)919 Ok(invite)
920}920}
921921
922/// `GET /settings` — the viewer's profile editor.922/// `GET /settings` — the Profile tab.
923pub async fn settings(923pub async fn settings(
924 State(state): State<AppState>,924 State(state): State<AppState>,
925 RequireUser(user): RequireUser,925 RequireUser(user): RequireUser,
926 jar: CookieJar,926 jar: CookieJar,
927 uri: Uri,927 uri: Uri,
928) -> AppResult<Response> {928) -> 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).
935pub 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.
948pub async fn settings_keys(
949 State(state): State<AppState>,
950 RequireUser(user): RequireUser,
951 jar: CookieJar,
952 uri: Uri,
953) -> AppResult<Response> {
930 let keys = state.store.keys_by_user(&user.id).await?;954 let keys = state.store.keys_by_user(&user.id).await?;
931 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());955 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.
961pub 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));
933 Ok((jar, page(&chrome, "Settings", body)).into_response())970 Ok((jar, page(&chrome, "Settings", body)).into_response())
934}971}
935972
@@ -1010,15 +1047,12 @@ fn norm(s: &str) -> Option<String> {
1010 (!t.is_empty()).then(|| t.to_string())1047 (!t.is_empty()).then(|| t.to_string())
1011}1048}
10121049
1013/// A single-field account form (username or email) with a CSRF token.1050/// The change-username form.
1014#[derive(Debug, Deserialize)]1051#[derive(Debug, Deserialize)]
1015pub struct AccountFieldForm {1052pub struct AccountFieldForm {
1016 /// The new username.1053 /// The new username.
1017 #[serde(default)]1054 #[serde(default)]
1018 username: String,1055 username: String,
1019 /// The new email.
1020 #[serde(default)]
1021 email: String,
1022 /// CSRF token.1056 /// CSRF token.
1023 #[serde(rename = "_csrf")]1057 #[serde(rename = "_csrf")]
1024 csrf: String,1058 csrf: String,
@@ -1040,7 +1074,7 @@ pub async fn account_username(
1040 .update_username(&user.id, form.username.trim())1074 .update_username(&user.id, form.username.trim())
1041 .await1075 .await
1042 {1076 {
1043 Ok(_) => Redirect::to("/settings").into_response(),1077 Ok(_) => Redirect::to("/settings/account").into_response(),
1044 Err(store::StoreError::Conflict { .. }) => {1078 Err(store::StoreError::Conflict { .. }) => {
1045 AppError::BadRequest("That username is already taken.".to_string()).into_response()1079 AppError::BadRequest("That username is already taken.".to_string()).into_response()
1046 }1080 }
@@ -1051,13 +1085,24 @@ pub async fn account_username(
1051 }1085 }
1052}1086}
10531087
1054/// `POST /settings/email` — change the viewer's email.1088/// The add-email form.
1055pub async fn account_email(1089#[derive(Debug, Deserialize)]
1090pub 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.
1100pub async fn email_add(
1056 State(state): State<AppState>,1101 State(state): State<AppState>,
1057 RequireUser(user): RequireUser,1102 RequireUser(user): RequireUser,
1058 jar: CookieJar,1103 jar: CookieJar,
1059 headers: HeaderMap,1104 headers: HeaderMap,
1060 Form(form): Form<AccountFieldForm>,1105 Form(form): Form<EmailAddForm>,
1061) -> Response {1106) -> Response {
1062 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {1107 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1063 return AppError::Forbidden.into_response();1108 return AppError::Forbidden.into_response();
@@ -1066,8 +1111,8 @@ pub async fn account_email(
1066 if !email.contains('@') {1111 if !email.contains('@') {
1067 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();1112 return AppError::BadRequest("Enter a valid email address.".to_string()).into_response();
1068 }1113 }
1069 match state.store.update_email(&user.id, email).await {1114 match state.store.add_email(&user.id, email).await {
1070 Ok(_) => Redirect::to("/settings").into_response(),1115 Ok(_) => Redirect::to("/settings/account").into_response(),
1071 Err(store::StoreError::Conflict { .. }) => {1116 Err(store::StoreError::Conflict { .. }) => {
1072 AppError::BadRequest("That email is already in use.".to_string()).into_response()1117 AppError::BadRequest("That email is already in use.".to_string()).into_response()
1073 }1118 }
@@ -1075,6 +1120,76 @@ pub async fn account_email(
1075 }1120 }
1076}1121}
10771122
1123/// An email-id form (set-primary or delete).
1124#[derive(Debug, Deserialize)]
1125pub 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.
1135pub 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.
1150pub 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)]
1166pub 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.
1176pub 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
1078/// The change-password form.1193/// The change-password form.
1079#[derive(Debug, Deserialize)]1194#[derive(Debug, Deserialize)]
1080pub struct PasswordForm {1195pub struct PasswordForm {
@@ -1127,7 +1242,7 @@ pub async fn account_password(
1127 }1242 }
1128 .await;1243 .await;
1129 match result {1244 match result {
1130 Ok(jar) => (jar, Redirect::to("/settings")).into_response(),1245 Ok(jar) => (jar, Redirect::to("/settings/account")).into_response(),
1131 Err(err) => err.into_response(),1246 Err(err) => err.into_response(),
1132 }1247 }
1133}1248}
@@ -1216,10 +1331,13 @@ pub async fn token_create(
1216 .tokens_by_user(&user.id)1331 .tokens_by_user(&user.id)
1217 .await1332 .await
1218 .unwrap_or_default();1333 .unwrap_or_default();
1219 let keys = state.store.keys_by_user(&user.id).await.unwrap_or_default();1334 let (jar, chrome) = build_chrome(
1220 let (jar, chrome) =1335 &state,
1221 build_chrome(&state, jar, Some(user.clone()), "/settings".to_string());1336 jar,
1222 let body = settings_body(&user, &chrome.csrf, &tokens, &keys, Some(&jwt));1337 Some(user.clone()),
1338 "/settings/tokens".to_string(),
1339 );
1340 let body = settings_shell("tokens", tokens_section(&chrome.csrf, &tokens, Some(&jwt)));
1223 (jar, page(&chrome, "Settings", body)).into_response()1341 (jar, page(&chrome, "Settings", body)).into_response()
1224 }1342 }
1225 Err(err) => err.into_response(),1343 Err(err) => err.into_response(),
@@ -1259,7 +1377,7 @@ pub async fn token_revoke(
1259 if owned {1377 if owned {
1260 let _ = state.store.revoke_token(&form.id).await;1378 let _ = state.store.revoke_token(&form.id).await;
1261 }1379 }
1262 Redirect::to("/settings").into_response()1380 Redirect::to("/settings/tokens").into_response()
1263}1381}
12641382
1265/// The add-key form.1383/// The add-key form.
@@ -1326,7 +1444,7 @@ pub async fn key_add(
1326 })1444 })
1327 .await1445 .await
1328 {1446 {
1329 Ok(_) => Redirect::to("/settings").into_response(),1447 Ok(_) => Redirect::to("/settings/keys").into_response(),
1330 Err(store::StoreError::Conflict { .. }) => {1448 Err(store::StoreError::Conflict { .. }) => {
1331 AppError::BadRequest("That key is already registered.".to_string()).into_response()1449 AppError::BadRequest("That key is already registered.".to_string()).into_response()
1332 }1450 }
@@ -1365,20 +1483,40 @@ pub async fn key_delete(
1365 if owned {1483 if owned {
1366 let _ = state.store.delete_key(&form.id).await;1484 let _ = state.store.delete_key(&form.id).await;
1367 }1485 }
1368 Redirect::to("/settings").into_response()1486 Redirect::to("/settings/keys").into_response()
1369}1487}
13701488
1371/// Render the settings page: avatar, profile, account credentials, and tokens.1489/// Render the settings page: avatar, profile, account credentials, and tokens.
1372fn settings_body(1490/// The settings page shell: a left tab nav and the active tab's content. Each tab
1373 user: &User,1491/// is its own route, so navigation works without JavaScript.
1374 csrf: &str,1492#[allow(clippy::needless_pass_by_value)] // `content` is an owned fragment embedded once.
1375 tokens: &[model::ApiToken],1493fn settings_shell(active: &str, content: Markup) -> Markup {
1376 keys: &[model::Key],1494 let tab = |href: &str, key: &str, label: &str| {
1377 new_token: Option<&str>,1495 let current = active == key;
1378) -> Markup {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.
1517fn profile_tab(user: &User, csrf: &str) -> Markup {
1379 let val = |v: &Option<String>| v.clone().unwrap_or_default();1518 let val = |v: &Option<String>| v.clone().unwrap_or_default();
1380 html! {1519 html! {
1381 h1 { "Settings" }
1382 section class="listing" {1520 section class="listing" {
1383 h2 { "Avatar" }1521 h2 { "Avatar" }
1384 div class="card avatar-settings" {1522 div class="card avatar-settings" {
@@ -1422,8 +1560,14 @@ fn settings_body(
1422 }1560 }
1423 }1561 }
1424 }1562 }
1563 }
1564}
1565
1566/// The Account tab: username, email(s), and password.
1567fn account_tab(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup {
1568 html! {
1425 section class="listing" {1569 section class="listing" {
1426 h2 { "Account" }1570 h2 { "Username" }
1427 div class="card" {1571 div class="card" {
1428 form method="post" action="/settings/username" {1572 form method="post" action="/settings/username" {
1429 input type="hidden" name="_csrf" value=(csrf);1573 input type="hidden" name="_csrf" value=(csrf);
@@ -1431,17 +1575,12 @@ fn settings_body(
1431 input type="text" id="username" name="username" value=(user.username) required;1575 input type="text" id="username" name="username" value=(user.username) required;
1432 button class="btn" type="submit" { "Change username" }1576 button class="btn" type="submit" { "Change username" }
1433 }1577 }
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 }
1440 p class="muted field-hint" {1578 p class="muted field-hint" {
1441 "Administrator: " (if user.is_admin { "yes" } else { "no" })1579 "Administrator: " (if user.is_admin { "yes" } else { "no" })
1442 }1580 }
1443 }1581 }
1444 }1582 }
1583 (emails_section(user, csrf, emails))
1445 section class="listing" {1584 section class="listing" {
1446 h2 { "Password" }1585 h2 { "Password" }
1447 div class="card" {1586 div class="card" {
@@ -1460,8 +1599,63 @@ fn settings_body(
1460 }1599 }
1461 }1600 }
1462 }1601 }
1463 (keys_section(csrf, keys))1602 }
1464 (tokens_section(csrf, tokens, new_token))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.
1608fn 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 }
1465 }1659 }
1466}1660}
14671661
crates/web/src/repo.rs +10 −0
@@ -1965,6 +1965,16 @@ fn profile_links(owner: &User) -> Vec<Markup> {
1965 if let Some(location) = non_empty(owner.location.as_ref()) {1965 if let Some(location) = non_empty(owner.location.as_ref()) {
1966 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });1966 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
1967 }1967 }
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 }
1968 for link in &owner.links {1978 for link in &owner.links {
1969 let href = normalize_url(link);1979 let href = normalize_url(link);
1970 let shown = href1980 let shown = href
crates/web/src/tests.rs +95 −12
@@ -452,20 +452,103 @@ async fn new_repo_page_requires_auth_and_renders() {
452}452}
453453
454#[tokio::test]454#[tokio::test]
455async fn settings_page_shows_account_and_tokens() {455async fn settings_tabs_render() {
456 let (_state, app) = app().await;456 let (_state, app) = app().await;
457 let cookie = login(&app).await;457 let cookie = login(&app).await;
458 let req = Request::builder()458 let get_tab = |uri: &'static str, cookie: String| {
459 .uri("/settings")459 let app = app.clone();
460 .header(header::COOKIE, cookie)460 async move {
461 .body(Body::empty())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]
486async 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")
462 .unwrap();523 .unwrap();
463 let res = app.oneshot(req).await.unwrap();524 assert!(!second.is_primary);
464 assert_eq!(res.status(), StatusCode::OK);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();
465 let html = body_string(res).await;547 let html = body_string(res).await;
466 assert!(html.contains("Change username"), "account section");548 assert!(
467 assert!(html.contains("Change password"), "password section");549 html.contains("second@example.com"),
468 assert!(html.contains("API tokens"), "tokens section");550 "email shown on profile"
551 );
469}552}
470553
471/// An app with self-registration enabled (and no seeded user).554/// An app with self-registration enabled (and no seeded user).
@@ -853,7 +936,7 @@ async fn settings_keys_add_list_and_delete() {
853936
854 // The settings page shows the keys section.937 // The settings page shows the keys section.
855 let req = Request::builder()938 let req = Request::builder()
856 .uri("/settings")939 .uri("/settings/keys")
857 .header(header::COOKIE, cookie.clone())940 .header(header::COOKIE, cookie.clone())
858 .body(Body::empty())941 .body(Body::empty())
859 .unwrap();942 .unwrap();
@@ -883,7 +966,7 @@ async fn settings_keys_add_list_and_delete() {
883966
884 // It renders on the settings page.967 // It renders on the settings page.
885 let req = Request::builder()968 let req = Request::builder()
886 .uri("/settings")969 .uri("/settings/keys")
887 .header(header::COOKIE, cookie.clone())970 .header(header::COOKIE, cookie.clone())
888 .body(Body::empty())971 .body(Body::empty())
889 .unwrap();972 .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.
3ALTER TABLE users ADD COLUMN email_public BOOLEAN NOT NULL DEFAULT FALSE;
4
5CREATE 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);
13CREATE UNIQUE INDEX idx_user_emails_lower ON user_emails (email_lower);
14CREATE INDEX idx_user_emails_user ON user_emails (user_id);
15
16-- Seed each existing user's current email as their primary address.
17INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at)
18SELECT 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.
3ALTER TABLE users ADD COLUMN email_public INTEGER NOT NULL DEFAULT 0;
4
5CREATE 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);
13CREATE UNIQUE INDEX idx_user_emails_lower ON user_emails (email_lower);
14CREATE INDEX idx_user_emails_user ON user_emails (user_id);
15
16-- Seed each existing user's current email as their primary address.
17INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at)
18SELECT id, id, email, email_lower, 1, created_at FROM users;