Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +8 −0
| @@ -825,6 +825,14 @@ body.drawer-open .drawer-backdrop { | |||
| 825 | .email-list { | 825 | .email-list { |
| 826 | margin-bottom: 0; | 826 | margin-bottom: 0; |
| 827 | } | 827 | } |
| 828 | .badge-verified { | ||
| 829 | color: var(--fb-success); | ||
| 830 | border-color: var(--fb-success); | ||
| 831 | } | ||
| 832 | .badge-unverified { | ||
| 833 | color: var(--fb-fg-muted); | ||
| 834 | border-color: var(--fb-border); | ||
| 835 | } | ||
| 828 | .email-visibility { | 836 | .email-visibility { |
| 829 | margin: 0; | 837 | margin: 0; |
| 830 | padding: 0.85rem 0; | 838 | padding: 0.85rem 0; |
crates/cli/src/user_cmd.rs +22 −0
| @@ -60,6 +60,12 @@ pub(crate) enum UserCommand { | |||
| 60 | /// The user to enable. | 60 | /// The user to enable. |
| 61 | name: String, | 61 | name: String, |
| 62 | }, | 62 | }, |
| 63 | /// Manually mark a user's primary email verified (for when SMTP is not set | ||
| 64 | /// up, or to vouch for an account). | ||
| 65 | Verify { | ||
| 66 | /// The user to verify. | ||
| 67 | name: String, | ||
| 68 | }, | ||
| 63 | /// Delete a user. Refuses if the user still owns repositories unless | 69 | /// Delete a user. Refuses if the user still owns repositories unless |
| 64 | /// `--purge` is given. | 70 | /// `--purge` is given. |
| 65 | Del { | 71 | Del { |
| @@ -99,6 +105,7 @@ pub(crate) fn run( | |||
| 99 | UserCommand::List => block_on(list(config_path, json)), | 105 | UserCommand::List => block_on(list(config_path, json)), |
| 100 | UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)), | 106 | UserCommand::Disable { name } => block_on(set_disabled(config_path, name, true)), |
| 101 | UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)), | 107 | UserCommand::Enable { name } => block_on(set_disabled(config_path, name, false)), |
| 108 | UserCommand::Verify { name } => block_on(verify(config_path, name)), | ||
| 102 | UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)), | 109 | UserCommand::Del { name, purge, yes } => block_on(del(config_path, name, *purge, *yes)), |
| 103 | } | 110 | } |
| 104 | } | 111 | } |
| @@ -282,6 +289,21 @@ async fn set_disabled( | |||
| 282 | Ok(ExitCode::SUCCESS) | 289 | Ok(ExitCode::SUCCESS) |
| 283 | } | 290 | } |
| 284 | 291 | ||
| 292 | /// Manually mark a user's primary email verified. | ||
| 293 | async fn verify(config_path: Option<&Path>, name: &str) -> anyhow::Result<ExitCode> { | ||
| 294 | let (_config, store) = open_store(config_path).await?; | ||
| 295 | let user = require_user(&store, name).await?; | ||
| 296 | if store.verify_primary_email(&user.id).await? { | ||
| 297 | println!( | ||
| 298 | "verified {}'s primary email ({})", | ||
| 299 | user.username, user.email | ||
| 300 | ); | ||
| 301 | } else { | ||
| 302 | println!("{} has no primary email to verify", user.username); | ||
| 303 | } | ||
| 304 | Ok(ExitCode::SUCCESS) | ||
| 305 | } | ||
| 306 | |||
| 285 | async fn del( | 307 | async fn del( |
| 286 | config_path: Option<&Path>, | 308 | config_path: Option<&Path>, |
| 287 | name: &str, | 309 | name: &str, |
crates/mail/src/templates.rs +8 −0
| @@ -12,6 +12,8 @@ pub enum Purpose { | |||
| 12 | Activate, | 12 | Activate, |
| 13 | /// A password-reset link. | 13 | /// A password-reset link. |
| 14 | Reset, | 14 | Reset, |
| 15 | /// An email-address verification link. | ||
| 16 | VerifyEmail, | ||
| 15 | } | 17 | } |
| 16 | 18 | ||
| 17 | impl Purpose { | 19 | impl Purpose { |
| @@ -21,6 +23,7 @@ impl Purpose { | |||
| 21 | match self { | 23 | match self { |
| 22 | Self::Activate => "activate", | 24 | Self::Activate => "activate", |
| 23 | Self::Reset => "reset", | 25 | Self::Reset => "reset", |
| 26 | Self::VerifyEmail => "verify-email", | ||
| 24 | } | 27 | } |
| 25 | } | 28 | } |
| 26 | } | 29 | } |
| @@ -51,6 +54,11 @@ pub fn render(instance_name: &str, link: &str, purpose: Purpose) -> Rendered { | |||
| 51 | format!("A password reset was requested for your {instance_name} account."), | 54 | format!("A password reset was requested for your {instance_name} account."), |
| 52 | "choose a new password", | 55 | "choose a new password", |
| 53 | ), | 56 | ), |
| 57 | Purpose::VerifyEmail => ( | ||
| 58 | format!("Verify your email for {instance_name}"), | ||
| 59 | format!("Confirm this email address to use it on {instance_name}."), | ||
| 60 | "verify this email address", | ||
| 61 | ), | ||
| 54 | }; | 62 | }; |
| 55 | 63 | ||
| 56 | let plain = format!( | 64 | let plain = format!( |
crates/model/src/entity.rs +10 −0
| @@ -114,10 +114,20 @@ pub struct UserEmail { | |||
| 114 | pub email: String, | 114 | pub email: String, |
| 115 | /// Whether this is the user's primary address. | 115 | /// Whether this is the user's primary address. |
| 116 | pub is_primary: bool, | 116 | pub is_primary: bool, |
| 117 | /// When the address was verified, or `None` if still unverified. | ||
| 118 | pub verified_at: Option<Timestamp>, | ||
| 117 | /// Creation time. | 119 | /// Creation time. |
| 118 | pub created_at: Timestamp, | 120 | pub created_at: Timestamp, |
| 119 | } | 121 | } |
| 120 | 122 | ||
| 123 | impl UserEmail { | ||
| 124 | /// Whether the address has been verified. | ||
| 125 | #[must_use] | ||
| 126 | pub fn verified(&self) -> bool { | ||
| 127 | self.verified_at.is_some() | ||
| 128 | } | ||
| 129 | } | ||
| 130 | |||
| 121 | /// A registered account. | 131 | /// A registered account. |
| 122 | #[derive(Debug, Clone, PartialEq, Eq)] | 132 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 123 | pub struct User { | 133 | pub struct User { |
crates/store/src/users.rs +131 −11
| @@ -132,10 +132,13 @@ impl Store { | |||
| 132 | .map_err(|e| { | 132 | .map_err(|e| { |
| 133 | map_conflict(e, "user", &[("email", "email"), ("username", "username")]) | 133 | map_conflict(e, "user", &[("email", "email"), ("username", "username")]) |
| 134 | })?; | 134 | })?; |
| 135 | // Seed the primary email row so `user_emails` holds every address. | 135 | // Seed the primary email row so `user_emails` holds every address. It is |
| 136 | // created verified — admin/CLI/invite accounts are trusted; the | ||
| 137 | // self-registration path explicitly re-opens verification afterwards. | ||
| 136 | sqlx::query( | 138 | sqlx::query( |
| 137 | "INSERT INTO user_emails (id, user_id, email, email_lower, is_primary, created_at) \ | 139 | "INSERT INTO user_emails \ |
| 138 | VALUES ($1, $2, $3, $4, $5, $6)", | 140 | (id, user_id, email, email_lower, is_primary, verified_at, created_at) \ |
| 141 | VALUES ($1, $2, $3, $4, $5, $6, $7)", | ||
| 139 | ) | 142 | ) |
| 140 | .bind(new_id()) | 143 | .bind(new_id()) |
| 141 | .bind(user.id.clone()) | 144 | .bind(user.id.clone()) |
| @@ -143,6 +146,7 @@ impl Store { | |||
| 143 | .bind(user.email.to_lowercase()) | 146 | .bind(user.email.to_lowercase()) |
| 144 | .bind(true) | 147 | .bind(true) |
| 145 | .bind(user.created_at) | 148 | .bind(user.created_at) |
| 149 | .bind(user.created_at) | ||
| 146 | .execute(&mut *tx) | 150 | .execute(&mut *tx) |
| 147 | .await | 151 | .await |
| 148 | .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?; | 152 | .map_err(|e| map_conflict(e, "user", &[("email_lower", "email")]))?; |
| @@ -250,7 +254,7 @@ impl Store { | |||
| 250 | /// Returns [`StoreError::Query`] on a database failure. | 254 | /// Returns [`StoreError::Query`] on a database failure. |
| 251 | pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> { | 255 | pub async fn emails_by_user(&self, user_id: &str) -> Result<Vec<model::UserEmail>, StoreError> { |
| 252 | let rows = sqlx::query( | 256 | let rows = sqlx::query( |
| 253 | "SELECT id, user_id, email, is_primary, created_at FROM user_emails \ | 257 | "SELECT id, user_id, email, is_primary, verified_at, created_at FROM user_emails \ |
| 254 | WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC", | 258 | WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC", |
| 255 | ) | 259 | ) |
| 256 | .bind(user_id) | 260 | .bind(user_id) |
| @@ -263,6 +267,7 @@ impl Store { | |||
| 263 | user_id: r.try_get("user_id")?, | 267 | user_id: r.try_get("user_id")?, |
| 264 | email: r.try_get("email")?, | 268 | email: r.try_get("email")?, |
| 265 | is_primary: self.get_bool(r, "is_primary")?, | 269 | is_primary: self.get_bool(r, "is_primary")?, |
| 270 | verified_at: r.try_get("verified_at")?, | ||
| 266 | created_at: r.try_get("created_at")?, | 271 | created_at: r.try_get("created_at")?, |
| 267 | }) | 272 | }) |
| 268 | }) | 273 | }) |
| @@ -286,6 +291,7 @@ impl Store { | |||
| 286 | user_id: user_id.to_string(), | 291 | user_id: user_id.to_string(), |
| 287 | email: email.to_string(), | 292 | email: email.to_string(), |
| 288 | is_primary: false, | 293 | is_primary: false, |
| 294 | verified_at: None, | ||
| 289 | created_at: now_ms(), | 295 | created_at: now_ms(), |
| 290 | }; | 296 | }; |
| 291 | sqlx::query( | 297 | sqlx::query( |
| @@ -322,7 +328,8 @@ impl Store { | |||
| 322 | Ok(deleted > 0) | 328 | Ok(deleted > 0) |
| 323 | } | 329 | } |
| 324 | 330 | ||
| 325 | /// Make one of a user's addresses primary, mirroring it into `users.email`. | 331 | /// Make one of a user's **verified** addresses primary, mirroring it into |
| 332 | /// `users.email`. Returns `false` if the address is missing or unverified. | ||
| 326 | /// | 333 | /// |
| 327 | /// # Errors | 334 | /// # Errors |
| 328 | /// | 335 | /// |
| @@ -332,12 +339,15 @@ impl Store { | |||
| 332 | user_id: &str, | 339 | user_id: &str, |
| 333 | email_id: &str, | 340 | email_id: &str, |
| 334 | ) -> Result<bool, StoreError> { | 341 | ) -> Result<bool, StoreError> { |
| 335 | // Confirm the address belongs to the user and capture its value. | 342 | // Confirm the address belongs to the user, is verified, and capture it. |
| 336 | let row = sqlx::query("SELECT email FROM user_emails WHERE id = $1 AND user_id = $2") | 343 | let row = sqlx::query( |
| 337 | .bind(email_id) | 344 | "SELECT email FROM user_emails \ |
| 338 | .bind(user_id) | 345 | WHERE id = $1 AND user_id = $2 AND verified_at IS NOT NULL", |
| 339 | .fetch_optional(&self.pool) | 346 | ) |
| 340 | .await?; | 347 | .bind(email_id) |
| 348 | .bind(user_id) | ||
| 349 | .fetch_optional(&self.pool) | ||
| 350 | .await?; | ||
| 341 | let Some(row) = row else { | 351 | let Some(row) = row else { |
| 342 | return Ok(false); | 352 | return Ok(false); |
| 343 | }; | 353 | }; |
| @@ -380,6 +390,116 @@ impl Store { | |||
| 380 | Ok(()) | 390 | Ok(()) |
| 381 | } | 391 | } |
| 382 | 392 | ||
| 393 | /// Begin (or restart) verification of an email address: mark it unverified, | ||
| 394 | /// drop any outstanding tokens, and store a fresh `token` valid for 24 hours. | ||
| 395 | /// The caller generates the random token and emails the link. | ||
| 396 | /// | ||
| 397 | /// # Errors | ||
| 398 | /// | ||
| 399 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 400 | pub async fn begin_email_verification( | ||
| 401 | &self, | ||
| 402 | email_id: &str, | ||
| 403 | token: &str, | ||
| 404 | ) -> Result<(), StoreError> { | ||
| 405 | let now = now_ms(); | ||
| 406 | let mut tx = self.pool.begin().await?; | ||
| 407 | sqlx::query("UPDATE user_emails SET verified_at = NULL WHERE id = $1") | ||
| 408 | .bind(email_id) | ||
| 409 | .execute(&mut *tx) | ||
| 410 | .await?; | ||
| 411 | sqlx::query("DELETE FROM email_tokens WHERE email_id = $1") | ||
| 412 | .bind(email_id) | ||
| 413 | .execute(&mut *tx) | ||
| 414 | .await?; | ||
| 415 | sqlx::query( | ||
| 416 | "INSERT INTO email_tokens (token, email_id, expires_at, created_at) \ | ||
| 417 | VALUES ($1, $2, $3, $4)", | ||
| 418 | ) | ||
| 419 | .bind(token) | ||
| 420 | .bind(email_id) | ||
| 421 | .bind(now + 24 * 60 * 60 * 1000) | ||
| 422 | .bind(now) | ||
| 423 | .execute(&mut *tx) | ||
| 424 | .await?; | ||
| 425 | tx.commit().await?; | ||
| 426 | Ok(()) | ||
| 427 | } | ||
| 428 | |||
| 429 | /// Redeem a verification token: mark its address verified and consume the | ||
| 430 | /// token. Returns the owning user id, or `None` if the token is unknown or | ||
| 431 | /// expired. | ||
| 432 | /// | ||
| 433 | /// # Errors | ||
| 434 | /// | ||
| 435 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 436 | pub async fn verify_email_token(&self, token: &str) -> Result<Option<String>, StoreError> { | ||
| 437 | let now = now_ms(); | ||
| 438 | let row = sqlx::query( | ||
| 439 | "SELECT t.email_id, e.user_id FROM email_tokens t \ | ||
| 440 | JOIN user_emails e ON e.id = t.email_id \ | ||
| 441 | WHERE t.token = $1 AND t.expires_at > $2", | ||
| 442 | ) | ||
| 443 | .bind(token) | ||
| 444 | .bind(now) | ||
| 445 | .fetch_optional(&self.pool) | ||
| 446 | .await?; | ||
| 447 | let Some(row) = row else { | ||
| 448 | return Ok(None); | ||
| 449 | }; | ||
| 450 | let email_id: String = row.try_get("email_id")?; | ||
| 451 | let user_id: String = row.try_get("user_id")?; | ||
| 452 | |||
| 453 | let mut tx = self.pool.begin().await?; | ||
| 454 | sqlx::query("UPDATE user_emails SET verified_at = $1 WHERE id = $2") | ||
| 455 | .bind(now) | ||
| 456 | .bind(&email_id) | ||
| 457 | .execute(&mut *tx) | ||
| 458 | .await?; | ||
| 459 | sqlx::query("DELETE FROM email_tokens WHERE token = $1") | ||
| 460 | .bind(token) | ||
| 461 | .execute(&mut *tx) | ||
| 462 | .await?; | ||
| 463 | tx.commit().await?; | ||
| 464 | Ok(Some(user_id)) | ||
| 465 | } | ||
| 466 | |||
| 467 | /// Look up an email row id owned by `user_id`, for resending verification. | ||
| 468 | /// | ||
| 469 | /// # Errors | ||
| 470 | /// | ||
| 471 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 472 | pub async fn email_owned_by( | ||
| 473 | &self, | ||
| 474 | user_id: &str, | ||
| 475 | email_id: &str, | ||
| 476 | ) -> Result<Option<model::UserEmail>, StoreError> { | ||
| 477 | Ok(self | ||
| 478 | .emails_by_user(user_id) | ||
| 479 | .await? | ||
| 480 | .into_iter() | ||
| 481 | .find(|e| e.id == email_id)) | ||
| 482 | } | ||
| 483 | |||
| 484 | /// Mark the user's primary email verified (the manual `user verify` path, | ||
| 485 | /// used when SMTP is unavailable). | ||
| 486 | /// | ||
| 487 | /// # Errors | ||
| 488 | /// | ||
| 489 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 490 | pub async fn verify_primary_email(&self, user_id: &str) -> Result<bool, StoreError> { | ||
| 491 | let updated = sqlx::query( | ||
| 492 | "UPDATE user_emails SET verified_at = $1 WHERE user_id = $2 AND is_primary = $3", | ||
| 493 | ) | ||
| 494 | .bind(now_ms()) | ||
| 495 | .bind(user_id) | ||
| 496 | .bind(true) | ||
| 497 | .execute(&self.pool) | ||
| 498 | .await? | ||
| 499 | .rows_affected(); | ||
| 500 | Ok(updated > 0) | ||
| 501 | } | ||
| 502 | |||
| 383 | /// Set (or clear) a user's password hash. | 503 | /// Set (or clear) a user's password hash. |
| 384 | /// | 504 | /// |
| 385 | /// Passing `Some(hash)` sets the credential and clears the | 505 | /// Passing `Some(hash)` sets the credential and clears the |
crates/web/src/lib.rs +2 −0
| @@ -149,6 +149,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 149 | "/invite/{token}", | 149 | "/invite/{token}", |
| 150 | get(pages::invite_form).post(pages::invite_submit), | 150 | get(pages::invite_form).post(pages::invite_submit), |
| 151 | ) | 151 | ) |
| 152 | .route("/verify-email/{token}", get(pages::verify_email)) | ||
| 152 | .route("/search", get(search::global)) | 153 | .route("/search", get(search::global)) |
| 153 | .route( | 154 | .route( |
| 154 | "/settings", | 155 | "/settings", |
| @@ -159,6 +160,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 159 | .route("/settings/emails", post(pages::email_add)) | 160 | .route("/settings/emails", post(pages::email_add)) |
| 160 | .route("/settings/emails/primary", post(pages::email_primary)) | 161 | .route("/settings/emails/primary", post(pages::email_primary)) |
| 161 | .route("/settings/emails/delete", post(pages::email_delete)) | 162 | .route("/settings/emails/delete", post(pages::email_delete)) |
| 163 | .route("/settings/emails/resend", post(pages::email_resend)) | ||
| 162 | .route("/settings/emails/visibility", post(pages::email_visibility)) | 164 | .route("/settings/emails/visibility", post(pages::email_visibility)) |
| 163 | .route("/settings/password", post(pages::account_password)) | 165 | .route("/settings/password", post(pages::account_password)) |
| 164 | .route( | 166 | .route( |
crates/web/src/pages.rs +109 −4
| @@ -765,6 +765,13 @@ pub async fn register_submit( | |||
| 765 | Err(err) => return AppError::from(err).into_response(), | 765 | Err(err) => return AppError::from(err).into_response(), |
| 766 | }; | 766 | }; |
| 767 | 767 | ||
| 768 | // Send a verification email for the new primary address (best-effort). | ||
| 769 | if let Ok(emails) = state.store.emails_by_user(&user.id).await | ||
| 770 | && let Some(primary) = emails.into_iter().find(|e| e.is_primary) | ||
| 771 | { | ||
| 772 | send_email_verification(&state, &primary).await; | ||
| 773 | } | ||
| 774 | |||
| 768 | match open_session(&state, &jar, &user, &headers).await { | 775 | match open_session(&state, &jar, &user, &headers).await { |
| 769 | Ok(jar) => (jar, Redirect::to("/")).into_response(), | 776 | Ok(jar) => (jar, Redirect::to("/")).into_response(), |
| 770 | Err(err) => err.into_response(), | 777 | Err(err) => err.into_response(), |
| @@ -1137,7 +1144,11 @@ pub async fn email_add( | |||
| 1137 | return AppError::BadRequest("Enter a valid email address.".to_string()).into_response(); | 1144 | return AppError::BadRequest("Enter a valid email address.".to_string()).into_response(); |
| 1138 | } | 1145 | } |
| 1139 | match state.store.add_email(&user.id, email).await { | 1146 | match state.store.add_email(&user.id, email).await { |
| 1140 | Ok(_) => Redirect::to("/settings/account").into_response(), | 1147 | Ok(added) => { |
| 1148 | // Send a verification link (best-effort; logged on failure). | ||
| 1149 | send_email_verification(&state, &added).await; | ||
| 1150 | Redirect::to("/settings/account").into_response() | ||
| 1151 | } | ||
| 1141 | Err(store::StoreError::Conflict { .. }) => { | 1152 | Err(store::StoreError::Conflict { .. }) => { |
| 1142 | AppError::BadRequest("That email is already in use.".to_string()).into_response() | 1153 | AppError::BadRequest("That email is already in use.".to_string()).into_response() |
| 1143 | } | 1154 | } |
| @@ -1145,6 +1156,80 @@ pub async fn email_add( | |||
| 1145 | } | 1156 | } |
| 1146 | } | 1157 | } |
| 1147 | 1158 | ||
| 1159 | /// Issue a fresh verification token for `email` and email the link. Best-effort: | ||
| 1160 | /// store or mail failures are logged, not surfaced (the user can resend). | ||
| 1161 | async fn send_email_verification(state: &AppState, email: &model::UserEmail) { | ||
| 1162 | let token = auth::new_secret(); | ||
| 1163 | if let Err(err) = state | ||
| 1164 | .store | ||
| 1165 | .begin_email_verification(&email.id, &token) | ||
| 1166 | .await | ||
| 1167 | { | ||
| 1168 | tracing::warn!(error = %err, "could not store email verification token"); | ||
| 1169 | return; | ||
| 1170 | } | ||
| 1171 | let base = state.config.instance.url.trim_end_matches('/'); | ||
| 1172 | let link = format!("{base}/verify-email/{token}"); | ||
| 1173 | if let Err(err) = state | ||
| 1174 | .mailer | ||
| 1175 | .send_invite( | ||
| 1176 | &state.config.instance.name, | ||
| 1177 | &email.email, | ||
| 1178 | None, | ||
| 1179 | &link, | ||
| 1180 | mail::Purpose::VerifyEmail, | ||
| 1181 | ) | ||
| 1182 | .await | ||
| 1183 | { | ||
| 1184 | tracing::warn!(error = %err, email = %email.email, "could not send verification email"); | ||
| 1185 | } | ||
| 1186 | } | ||
| 1187 | |||
| 1188 | /// `GET /verify-email/{token}` — redeem an email verification link. | ||
| 1189 | pub async fn verify_email( | ||
| 1190 | State(state): State<AppState>, | ||
| 1191 | jar: CookieJar, | ||
| 1192 | Path(token): Path<String>, | ||
| 1193 | ) -> Response { | ||
| 1194 | match state.store.verify_email_token(&token).await { | ||
| 1195 | Ok(Some(_)) => { | ||
| 1196 | let (jar, chrome) = build_chrome(&state, jar, None, "/verify-email".to_string()); | ||
| 1197 | let body = html! { | ||
| 1198 | div class="auth-wrap" { | ||
| 1199 | div class="auth-card" { | ||
| 1200 | h1 { "Email verified" } | ||
| 1201 | p class="muted" { "Your email address has been verified." } | ||
| 1202 | p { a class="btn btn-primary" href="/settings/account" { "Back to settings" } } | ||
| 1203 | } | ||
| 1204 | } | ||
| 1205 | }; | ||
| 1206 | (jar, page(&chrome, "Email verified", body)).into_response() | ||
| 1207 | } | ||
| 1208 | Ok(None) => { | ||
| 1209 | AppError::BadRequest("That verification link is invalid or has expired.".to_string()) | ||
| 1210 | .into_response() | ||
| 1211 | } | ||
| 1212 | Err(err) => AppError::from(err).into_response(), | ||
| 1213 | } | ||
| 1214 | } | ||
| 1215 | |||
| 1216 | /// `POST /settings/emails/resend` — resend a verification email. | ||
| 1217 | pub async fn email_resend( | ||
| 1218 | State(state): State<AppState>, | ||
| 1219 | RequireUser(user): RequireUser, | ||
| 1220 | jar: CookieJar, | ||
| 1221 | headers: HeaderMap, | ||
| 1222 | Form(form): Form<EmailIdForm>, | ||
| 1223 | ) -> Response { | ||
| 1224 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 1225 | return AppError::Forbidden.into_response(); | ||
| 1226 | } | ||
| 1227 | if let Ok(Some(email)) = state.store.email_owned_by(&user.id, &form.id).await { | ||
| 1228 | send_email_verification(&state, &email).await; | ||
| 1229 | } | ||
| 1230 | Redirect::to("/settings/account").into_response() | ||
| 1231 | } | ||
| 1232 | |||
| 1148 | /// An email-id form (set-primary or delete). | 1233 | /// An email-id form (set-primary or delete). |
| 1149 | #[derive(Debug, Deserialize)] | 1234 | #[derive(Debug, Deserialize)] |
| 1150 | pub struct EmailIdForm { | 1235 | pub struct EmailIdForm { |
| @@ -1167,8 +1252,14 @@ pub async fn email_primary( | |||
| 1167 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | 1252 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { |
| 1168 | return AppError::Forbidden.into_response(); | 1253 | return AppError::Forbidden.into_response(); |
| 1169 | } | 1254 | } |
| 1170 | let _ = state.store.set_primary_email(&user.id, &form.id).await; | 1255 | match state.store.set_primary_email(&user.id, &form.id).await { |
| 1171 | Redirect::to("/settings/account").into_response() | 1256 | Ok(true) => Redirect::to("/settings/account").into_response(), |
| 1257 | Ok(false) => { | ||
| 1258 | AppError::BadRequest("Verify the email address before making it primary.".to_string()) | ||
| 1259 | .into_response() | ||
| 1260 | } | ||
| 1261 | Err(err) => AppError::from(err).into_response(), | ||
| 1262 | } | ||
| 1172 | } | 1263 | } |
| 1173 | 1264 | ||
| 1174 | /// `POST /settings/emails/delete` — remove one of the viewer's secondary emails. | 1265 | /// `POST /settings/emails/delete` — remove one of the viewer's secondary emails. |
| @@ -1642,15 +1733,29 @@ fn emails_section(user: &User, csrf: &str, emails: &[model::UserEmail]) -> Marku | |||
| 1642 | span class="entry-row-title" { | 1733 | span class="entry-row-title" { |
| 1643 | (e.email) | 1734 | (e.email) |
| 1644 | @if e.is_primary { " " span class="badge" { "primary" } } | 1735 | @if e.is_primary { " " span class="badge" { "primary" } } |
| 1736 | @if e.verified() { | ||
| 1737 | " " span class="badge badge-verified" { "verified" } | ||
| 1738 | } @else { | ||
| 1739 | " " span class="badge badge-unverified" { "unverified" } | ||
| 1740 | } | ||
| 1645 | } | 1741 | } |
| 1646 | } | 1742 | } |
| 1647 | div class="entry-row-actions" { | 1743 | div class="entry-row-actions" { |
| 1648 | @if !e.is_primary { | 1744 | @if !e.verified() { |
| 1745 | form method="post" action="/settings/emails/resend" { | ||
| 1746 | input type="hidden" name="_csrf" value=(csrf); | ||
| 1747 | input type="hidden" name="id" value=(e.id); | ||
| 1748 | button class="btn" type="submit" { "Resend" } | ||
| 1749 | } | ||
| 1750 | } | ||
| 1751 | @if !e.is_primary && e.verified() { | ||
| 1649 | form method="post" action="/settings/emails/primary" { | 1752 | form method="post" action="/settings/emails/primary" { |
| 1650 | input type="hidden" name="_csrf" value=(csrf); | 1753 | input type="hidden" name="_csrf" value=(csrf); |
| 1651 | input type="hidden" name="id" value=(e.id); | 1754 | input type="hidden" name="id" value=(e.id); |
| 1652 | button class="btn" type="submit" { "Make primary" } | 1755 | button class="btn" type="submit" { "Make primary" } |
| 1653 | } | 1756 | } |
| 1757 | } | ||
| 1758 | @if !e.is_primary { | ||
| 1654 | form method="post" action="/settings/emails/delete" { | 1759 | form method="post" action="/settings/emails/delete" { |
| 1655 | input type="hidden" name="_csrf" value=(csrf); | 1760 | input type="hidden" name="_csrf" value=(csrf); |
| 1656 | input type="hidden" name="id" value=(e.id); | 1761 | input type="hidden" name="id" value=(e.id); |
crates/web/src/tests.rs +30 −1
| @@ -522,9 +522,38 @@ async fn emails_add_set_primary_and_publish_on_profile() { | |||
| 522 | .find(|e| e.email == "second@example.com") | 522 | .find(|e| e.email == "second@example.com") |
| 523 | .unwrap(); | 523 | .unwrap(); |
| 524 | assert!(!second.is_primary); | 524 | assert!(!second.is_primary); |
| 525 | assert!(!second.verified(), "added emails start unverified"); | ||
| 525 | let second_id = second.id.clone(); | 526 | let second_id = second.id.clone(); |
| 526 | 527 | ||
| 527 | // Make it primary; users.email mirrors it. | 528 | // A new address must be verified before it can become primary. |
| 529 | let res = post( | ||
| 530 | "/settings/emails/primary", | ||
| 531 | format!("id={second_id}&_csrf={token}"), | ||
| 532 | cookie.clone(), | ||
| 533 | ) | ||
| 534 | .await; | ||
| 535 | assert_eq!( | ||
| 536 | res.status(), | ||
| 537 | StatusCode::BAD_REQUEST, | ||
| 538 | "unverified cannot be primary" | ||
| 539 | ); | ||
| 540 | |||
| 541 | // Exercise the verification flow: token issued, then redeemed. | ||
| 542 | state | ||
| 543 | .store | ||
| 544 | .begin_email_verification(&second_id, "verify-token") | ||
| 545 | .await | ||
| 546 | .unwrap(); | ||
| 547 | assert!( | ||
| 548 | state | ||
| 549 | .store | ||
| 550 | .verify_email_token("verify-token") | ||
| 551 | .await | ||
| 552 | .unwrap() | ||
| 553 | .is_some() | ||
| 554 | ); | ||
| 555 | |||
| 556 | // Now it can be made primary; users.email mirrors it. | ||
| 528 | let res = post( | 557 | let res = post( |
| 529 | "/settings/emails/primary", | 558 | "/settings/emails/primary", |
| 530 | format!("id={second_id}&_csrf={token}"), | 559 | format!("id={second_id}&_csrf={token}"), |
migrations/postgres/0009_email_verification.sql +13 −0
| @@ -0,0 +1,13 @@ | |||
| 1 | -- Email-address verification: a nullable verified timestamp plus single-use, | ||
| 2 | -- expiring verification tokens. Addresses that predate this migration are | ||
| 3 | -- trusted (back-filled as verified). | ||
| 4 | ALTER TABLE user_emails ADD COLUMN verified_at BIGINT; | ||
| 5 | UPDATE user_emails SET verified_at = created_at; | ||
| 6 | |||
| 7 | CREATE TABLE email_tokens ( | ||
| 8 | token TEXT PRIMARY KEY, | ||
| 9 | email_id TEXT NOT NULL REFERENCES user_emails(id) ON DELETE CASCADE, | ||
| 10 | expires_at BIGINT NOT NULL, | ||
| 11 | created_at BIGINT NOT NULL | ||
| 12 | ); | ||
| 13 | CREATE INDEX idx_email_tokens_email ON email_tokens (email_id); | ||
migrations/sqlite/0009_email_verification.sql +13 −0
| @@ -0,0 +1,13 @@ | |||
| 1 | -- Email-address verification: a nullable verified timestamp plus single-use, | ||
| 2 | -- expiring verification tokens. Addresses that predate this migration are | ||
| 3 | -- trusted (back-filled as verified). | ||
| 4 | ALTER TABLE user_emails ADD COLUMN verified_at BIGINT; | ||
| 5 | UPDATE user_emails SET verified_at = created_at; | ||
| 6 | |||
| 7 | CREATE TABLE email_tokens ( | ||
| 8 | token TEXT PRIMARY KEY, | ||
| 9 | email_id TEXT NOT NULL REFERENCES user_emails(id) ON DELETE CASCADE, | ||
| 10 | expires_at BIGINT NOT NULL, | ||
| 11 | created_at BIGINT NOT NULL | ||
| 12 | ); | ||
| 13 | CREATE INDEX idx_email_tokens_email ON email_tokens (email_id); | ||