Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +75 −0
| @@ -595,6 +595,81 @@ body.drawer-open .drawer-backdrop { | |||
| 595 | 595 | flex: none; | |
| 596 | 596 | } | |
| 597 | 597 | ||
| 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 | + | ||
| 598 | 673 | label { | |
| 599 | 674 | display: block; | |
| 600 | 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 | } | |
| 104 | 104 | ||
| 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 | + | ||
| 105 | 121 | /// A registered account. | |
| 106 | 122 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 107 | 123 | pub struct User { | |
| @@ -109,8 +125,11 @@ pub struct User { | |||
| 109 | 125 | pub id: String, | |
| 110 | 126 | /// The name as the user typed it (case preserved for display). | |
| 111 | 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 | 130 | pub email: String, | |
| 131 | + | /// Whether the primary email is shown on the public profile. | |
| 132 | + | pub email_public: bool, | |
| 114 | 133 | /// Optional human-friendly display name. | |
| 115 | 134 | pub display_name: Option<String>, | |
| 116 | 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; | |||
| 13 | 13 | ||
| 14 | 14 | pub use entity::{ | |
| 15 | 15 | ApiToken, Comment, Group, Invite, Issue, IssueState, Key, KeyKind, Label, PullRequest, Repo, | |
| 16 | - | Timestamp, User, Visibility, | |
| 16 | + | Timestamp, User, UserEmail, Visibility, | |
| 17 | 17 | }; | |
| 18 | 18 | 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}; | |||
| 12 | 12 | ||
| 13 | 13 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | |
| 14 | 14 | /// 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, \ | |
| 17 | 17 | must_change_password, is_admin, disabled_at, created_at, updated_at"; | |
| 18 | 18 | ||
| 19 | 19 | /// The editable profile fields, set together by the settings page. Each `None` | |
| @@ -94,6 +94,7 @@ impl Store { | |||
| 94 | 94 | id: new_id(), | |
| 95 | 95 | username: new.username, | |
| 96 | 96 | email: new.email, | |
| 97 | + | email_public: false, | |
| 97 | 98 | display_name: new.display_name, | |
| 98 | 99 | pronouns: None, | |
| 99 | 100 | bio: None, | |
| @@ -108,6 +109,7 @@ impl Store { | |||
| 108 | 109 | updated_at: now, | |
| 109 | 110 | }; | |
| 110 | 111 | ||
| 112 | + | let mut tx = self.pool.begin().await?; | |
| 111 | 113 | let sql = "INSERT INTO users \ | |
| 112 | 114 | (id, username, username_lower, email, email_lower, display_name, \ | |
| 113 | 115 | password_hash, must_change_password, is_admin, disabled_at, created_at, updated_at) \ | |
| @@ -125,11 +127,26 @@ impl Store { | |||
| 125 | 127 | .bind(user.disabled_at) | |
| 126 | 128 | .bind(user.created_at) | |
| 127 | 129 | .bind(user.updated_at) | |
| 128 | - | .execute(&self.pool) | |
| 130 | + | .execute(&mut *tx) | |
| 129 | 131 | .await | |
| 130 | 132 | .map_err(|e| { | |
| 131 | 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?; | |
| 133 | 150 | ||
| 134 | 151 | Ok(user) | |
| 135 | 152 | } | |
| @@ -226,6 +243,143 @@ impl Store { | |||
| 226 | 243 | Ok(updated > 0) | |
| 227 | 244 | } | |
| 228 | 245 | ||
| 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 | 383 | /// Set (or clear) a user's password hash. | |
| 230 | 384 | /// | |
| 231 | 385 | /// Passing `Some(hash)` sets the credential and clears the | |
| @@ -374,6 +528,7 @@ impl Store { | |||
| 374 | 528 | id: row.try_get("id")?, | |
| 375 | 529 | username: row.try_get("username")?, | |
| 376 | 530 | email: row.try_get("email")?, | |
| 531 | + | email_public: self.get_bool(row, "email_public")?, | |
| 377 | 532 | display_name: row.try_get("display_name")?, | |
| 378 | 533 | pronouns: row.try_get("pronouns")?, | |
| 379 | 534 | bio: row.try_get("bio")?, | |
crates/web/src/icons.rs +4 −0
| @@ -33,6 +33,7 @@ pub enum Icon { | |||
| 33 | 33 | Dashboard, | |
| 34 | 34 | Compass, | |
| 35 | 35 | MapPin, | |
| 36 | + | Mail, | |
| 36 | 37 | Globe, | |
| 37 | 38 | Download, | |
| 38 | 39 | Github, | |
| @@ -100,6 +101,9 @@ impl Icon { | |||
| 100 | 101 | Icon::MapPin => { | |
| 101 | 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 | 107 | Icon::Globe => { | |
| 104 | 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 | 152 | "/settings", | |
| 153 | 153 | get(pages::settings).post(pages::settings_submit), | |
| 154 | 154 | ) | |
| 155 | + | .route("/settings/account", get(pages::settings_account)) | |
| 155 | 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 | 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 | 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 | 171 | .route("/settings/keys/delete", post(pages::key_delete)) | |
| 162 | 172 | .route("/settings/avatar", post(avatar::upload)) | |
| 163 | 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 | 919 | Ok(invite) | |
| 920 | 920 | } | |
| 921 | 921 | ||
| 922 | - | /// `GET /settings` — the viewer's profile editor. | |
| 922 | + | /// `GET /settings` — the Profile tab. | |
| 923 | 923 | pub async fn settings( | |
| 924 | 924 | State(state): State<AppState>, | |
| 925 | 925 | RequireUser(user): RequireUser, | |
| 926 | 926 | jar: CookieJar, | |
| 927 | 927 | uri: Uri, | |
| 928 | 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). | |
| 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> { | |
| 930 | 954 | let keys = state.store.keys_by_user(&user.id).await?; | |
| 931 | 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. | |
| 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)); | |
| 933 | 970 | Ok((jar, page(&chrome, "Settings", body)).into_response()) | |
| 934 | 971 | } | |
| 935 | 972 | ||
| @@ -1010,15 +1047,12 @@ fn norm(s: &str) -> Option<String> { | |||
| 1010 | 1047 | (!t.is_empty()).then(|| t.to_string()) | |
| 1011 | 1048 | } | |
| 1012 | 1049 | ||
| 1013 | - | /// A single-field account form (username or email) with a CSRF token. | |
| 1050 | + | /// The change-username form. | |
| 1014 | 1051 | #[derive(Debug, Deserialize)] | |
| 1015 | 1052 | pub struct AccountFieldForm { | |
| 1016 | 1053 | /// The new username. | |
| 1017 | 1054 | #[serde(default)] | |
| 1018 | 1055 | username: String, | |
| 1019 | - | /// The new email. | |
| 1020 | - | #[serde(default)] | |
| 1021 | - | email: String, | |
| 1022 | 1056 | /// CSRF token. | |
| 1023 | 1057 | #[serde(rename = "_csrf")] | |
| 1024 | 1058 | csrf: String, | |
| @@ -1040,7 +1074,7 @@ pub async fn account_username( | |||
| 1040 | 1074 | .update_username(&user.id, form.username.trim()) | |
| 1041 | 1075 | .await | |
| 1042 | 1076 | { | |
| 1043 | - | Ok(_) => Redirect::to("/settings").into_response(), | |
| 1077 | + | Ok(_) => Redirect::to("/settings/account").into_response(), | |
| 1044 | 1078 | Err(store::StoreError::Conflict { .. }) => { | |
| 1045 | 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 | } | |
| 1053 | 1087 | ||
| 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( | |
| 1056 | 1101 | State(state): State<AppState>, | |
| 1057 | 1102 | RequireUser(user): RequireUser, | |
| 1058 | 1103 | jar: CookieJar, | |
| 1059 | 1104 | headers: HeaderMap, | |
| 1060 | - | Form(form): Form<AccountFieldForm>, | |
| 1105 | + | Form(form): Form<EmailAddForm>, | |
| 1061 | 1106 | ) -> Response { | |
| 1062 | 1107 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 1063 | 1108 | return AppError::Forbidden.into_response(); | |
| @@ -1066,8 +1111,8 @@ pub async fn account_email( | |||
| 1066 | 1111 | if !email.contains('@') { | |
| 1067 | 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 { | |
| 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(), | |
| 1071 | 1116 | Err(store::StoreError::Conflict { .. }) => { | |
| 1072 | 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 | } | |
| 1077 | 1122 | ||
| 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 | + | ||
| 1078 | 1193 | /// The change-password form. | |
| 1079 | 1194 | #[derive(Debug, Deserialize)] | |
| 1080 | 1195 | pub struct PasswordForm { | |
| @@ -1127,7 +1242,7 @@ pub async fn account_password( | |||
| 1127 | 1242 | } | |
| 1128 | 1243 | .await; | |
| 1129 | 1244 | match result { | |
| 1130 | - | Ok(jar) => (jar, Redirect::to("/settings")).into_response(), | |
| 1245 | + | Ok(jar) => (jar, Redirect::to("/settings/account")).into_response(), | |
| 1131 | 1246 | Err(err) => err.into_response(), | |
| 1132 | 1247 | } | |
| 1133 | 1248 | } | |
| @@ -1216,10 +1331,13 @@ pub async fn token_create( | |||
| 1216 | 1331 | .tokens_by_user(&user.id) | |
| 1217 | 1332 | .await | |
| 1218 | 1333 | .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))); | |
| 1223 | 1341 | (jar, page(&chrome, "Settings", body)).into_response() | |
| 1224 | 1342 | } | |
| 1225 | 1343 | Err(err) => err.into_response(), | |
| @@ -1259,7 +1377,7 @@ pub async fn token_revoke( | |||
| 1259 | 1377 | if owned { | |
| 1260 | 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 | } | |
| 1264 | 1382 | ||
| 1265 | 1383 | /// The add-key form. | |
| @@ -1326,7 +1444,7 @@ pub async fn key_add( | |||
| 1326 | 1444 | }) | |
| 1327 | 1445 | .await | |
| 1328 | 1446 | { | |
| 1329 | - | Ok(_) => Redirect::to("/settings").into_response(), | |
| 1447 | + | Ok(_) => Redirect::to("/settings/keys").into_response(), | |
| 1330 | 1448 | Err(store::StoreError::Conflict { .. }) => { | |
| 1331 | 1449 | AppError::BadRequest("That key is already registered.".to_string()).into_response() | |
| 1332 | 1450 | } | |
| @@ -1365,20 +1483,40 @@ pub async fn key_delete( | |||
| 1365 | 1483 | if owned { | |
| 1366 | 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 | } | |
| 1370 | 1488 | ||
| 1371 | 1489 | /// 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 { | |
| 1379 | 1518 | let val = |v: &Option<String>| v.clone().unwrap_or_default(); | |
| 1380 | 1519 | html! { | |
| 1381 | - | h1 { "Settings" } | |
| 1382 | 1520 | section class="listing" { | |
| 1383 | 1521 | h2 { "Avatar" } | |
| 1384 | 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. | |
| 1567 | + | fn account_tab(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Markup { | |
| 1568 | + | html! { | |
| 1425 | 1569 | section class="listing" { | |
| 1426 | - | h2 { "Account" } | |
| 1570 | + | h2 { "Username" } | |
| 1427 | 1571 | div class="card" { | |
| 1428 | 1572 | form method="post" action="/settings/username" { | |
| 1429 | 1573 | input type="hidden" name="_csrf" value=(csrf); | |
| @@ -1431,17 +1575,12 @@ fn settings_body( | |||
| 1431 | 1575 | input type="text" id="username" name="username" value=(user.username) required; | |
| 1432 | 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 | 1578 | p class="muted field-hint" { | |
| 1441 | 1579 | "Administrator: " (if user.is_admin { "yes" } else { "no" }) | |
| 1442 | 1580 | } | |
| 1443 | 1581 | } | |
| 1444 | 1582 | } | |
| 1583 | + | (emails_section(user, csrf, emails)) | |
| 1445 | 1584 | section class="listing" { | |
| 1446 | 1585 | h2 { "Password" } | |
| 1447 | 1586 | div class="card" { | |
| @@ -1460,8 +1599,63 @@ fn settings_body( | |||
| 1460 | 1599 | } | |
| 1461 | 1600 | } | |
| 1462 | 1601 | } | |
| 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 | + | } | |
| 1465 | 1659 | } | |
| 1466 | 1660 | } | |
| 1467 | 1661 | ||
crates/web/src/repo.rs +10 −0
| @@ -1965,6 +1965,16 @@ fn profile_links(owner: &User) -> Vec<Markup> { | |||
| 1965 | 1965 | if let Some(location) = non_empty(owner.location.as_ref()) { | |
| 1966 | 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 | 1978 | for link in &owner.links { | |
| 1969 | 1979 | let href = normalize_url(link); | |
| 1970 | 1980 | 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 | } | |
| 453 | 453 | ||
| 454 | 454 | #[tokio::test] | |
| 455 | - | async fn settings_page_shows_account_and_tokens() { | |
| 455 | + | async fn settings_tabs_render() { | |
| 456 | 456 | let (_state, app) = app().await; | |
| 457 | 457 | 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") | |
| 462 | 523 | .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(); | |
| 465 | 547 | 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 | + | ); | |
| 469 | 552 | } | |
| 470 | 553 | ||
| 471 | 554 | /// An app with self-registration enabled (and no seeded user). | |
| @@ -853,7 +936,7 @@ async fn settings_keys_add_list_and_delete() { | |||
| 853 | 936 | ||
| 854 | 937 | // The settings page shows the keys section. | |
| 855 | 938 | let req = Request::builder() | |
| 856 | - | .uri("/settings") | |
| 939 | + | .uri("/settings/keys") | |
| 857 | 940 | .header(header::COOKIE, cookie.clone()) | |
| 858 | 941 | .body(Body::empty()) | |
| 859 | 942 | .unwrap(); | |
| @@ -883,7 +966,7 @@ async fn settings_keys_add_list_and_delete() { | |||
| 883 | 966 | ||
| 884 | 967 | // It renders on the settings page. | |
| 885 | 968 | let req = Request::builder() | |
| 886 | - | .uri("/settings") | |
| 969 | + | .uri("/settings/keys") | |
| 887 | 970 | .header(header::COOKIE, cookie.clone()) | |
| 888 | 971 | .body(Body::empty()) | |
| 889 | 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. | |
| 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; | |