Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +75 −0
| @@ -595,6 +595,81 @@ body.drawer-open .drawer-backdrop { | |||
| 595 | flex: none; | 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 | label { | 673 | label { |
| 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 | } |
| 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 | /// A registered account. | 121 | /// A registered account. |
| 106 | #[derive(Debug, Clone, PartialEq, Eq)] | 122 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 107 | pub struct User { | 123 | pub 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; | |||
| 13 | 13 | ||
| 14 | pub use entity::{ | 14 | pub 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 | }; |
| 18 | pub use name::{MAX_NAME_LEN, NameError, RESERVED_NAMES, validate_name}; | 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 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | 13 | /// 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. |
| 15 | pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \ | 15 | pub(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"; |
| 18 | 18 | ||
| 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 | }; |
| 110 | 111 | ||
| 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 | .await | 131 | .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?; | ||
| 133 | 150 | ||
| 134 | Ok(user) | 151 | Ok(user) |
| 135 | } | 152 | } |
| @@ -226,6 +243,143 @@ impl Store { | |||
| 226 | Ok(updated > 0) | 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 | /// 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 the | 385 | /// 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 | } |
| 921 | 921 | ||
| 922 | /// `GET /settings` — the viewer's profile editor. | 922 | /// `GET /settings` — the Profile tab. |
| 923 | pub async fn settings( | 923 | pub 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). | ||
| 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 | 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. | ||
| 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 | Ok((jar, page(&chrome, "Settings", body)).into_response()) | 970 | Ok((jar, page(&chrome, "Settings", body)).into_response()) |
| 934 | } | 971 | } |
| 935 | 972 | ||
| @@ -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 | } |
| 1012 | 1049 | ||
| 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)] |
| 1015 | pub struct AccountFieldForm { | 1052 | pub 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 | .await | 1075 | .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 | } |
| 1053 | 1087 | ||
| 1054 | /// `POST /settings/email` — change the viewer's email. | 1088 | /// The add-email form. |
| 1055 | pub async fn account_email( | 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 | 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 | } |
| 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 | /// The change-password form. | 1193 | /// The change-password form. |
| 1079 | #[derive(Debug, Deserialize)] | 1194 | #[derive(Debug, Deserialize)] |
| 1080 | pub struct PasswordForm { | 1195 | pub 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 | .await | 1332 | .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 | } |
| 1264 | 1382 | ||
| 1265 | /// The add-key form. | 1383 | /// The add-key form. |
| @@ -1326,7 +1444,7 @@ pub async fn key_add( | |||
| 1326 | }) | 1444 | }) |
| 1327 | .await | 1445 | .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 | } |
| 1370 | 1488 | ||
| 1371 | /// Render the settings page: avatar, profile, account credentials, and tokens. | 1489 | /// Render the settings page: avatar, profile, account credentials, and tokens. |
| 1372 | fn 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], | 1493 | fn 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. | ||
| 1517 | fn 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. | ||
| 1567 | fn 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. | ||
| 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 | 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 = href | 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 | #[tokio::test] | 454 | #[tokio::test] |
| 455 | async fn settings_page_shows_account_and_tokens() { | 455 | async 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] | ||
| 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 | .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 | } |
| 470 | 553 | ||
| 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() { | |||
| 853 | 936 | ||
| 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() { | |||
| 883 | 966 | ||
| 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. | ||
| 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; | ||