diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index b0058b6..9f92713 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -286,6 +286,11 @@ set-password-submit = Set password set-password-invalid = This link is invalid or has expired. set-password-weak = Password must be at least 8 characters. set-password-mismatch = Passwords don't match. +resend-verification-title = Resend verification email +resend-verification-intro = Enter your email and we'll send a fresh verification link. +resend-verification-submit = Resend +resend-verification-done = If that email belongs to an unverified account, we've sent a new verification link. Check your inbox (and spam). You can request another in a minute. +login-resend = Didn't get the verification email? Resend it order-confirmed-title = Thank you for your order! order-confirmed-sub = We have received your order. order-number = Order number diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index e15843f..6453d0c 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -286,6 +286,11 @@ set-password-submit = Nastaviť heslo set-password-invalid = Odkaz je neplatný alebo vypršal. set-password-weak = Heslo musí mať aspoň 8 znakov. set-password-mismatch = Heslá sa nezhodujú. +resend-verification-title = Znova odoslať overovací e-mail +resend-verification-intro = Zadajte svoj e-mail a pošleme vám nový overovací odkaz. +resend-verification-submit = Odoslať znova +resend-verification-done = Ak k tomuto e-mailu patrí neoverený účet, poslali sme naň nový overovací odkaz. Skontrolujte si schránku aj priečinok so spamom. Ďalšiu žiadosť môžete odoslať o minútu. +login-resend = Nedostali ste overovací e-mail? Poslať znova order-confirmed-title = Ďakujeme za objednávku! order-confirmed-sub = Vašu objednávku sme prijali. order-number = Číslo objednávky diff --git a/assets/views/auth/login.html b/assets/views/auth/login.html index bb053ea..be04a7f 100644 --- a/assets/views/auth/login.html +++ b/assets/views/auth/login.html @@ -22,6 +22,9 @@ {% if error == "unverified" %} {{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }} +

+ {{ t(key="login-resend", lang=lang | default(value='sk')) }} +

{% elif error %} {{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }} {% endif %} diff --git a/assets/views/auth/resend_verification.html b/assets/views/auth/resend_verification.html new file mode 100644 index 0000000..98cddde --- /dev/null +++ b/assets/views/auth/resend_verification.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% import "macros/ui.html" as ui %} + +{% block title %}{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}{% endblock title %} + +{% block content %} +
+
+
+ {{ t(key="brand", lang=lang | default(value='sk')) }} + {{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }} +
+ +
+

{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}

+ + {% if done %} +
+ {{ t(key="resend-verification-done", lang=lang | default(value='sk')) }} +
+

+ {{ t(key="nav-login", lang=lang | default(value='sk')) }} +

+ {% else %} +

{{ t(key="resend-verification-intro", lang=lang | default(value='sk')) }}

+
+
+ + {{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }} +
+ {{ ui::button(label=t(key="resend-verification-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }} +
+ {% endif %} +
+
+
+{% endblock content %} diff --git a/src/controllers/auth_pages.rs b/src/controllers/auth_pages.rs index 618f7e9..9b0df32 100644 --- a/src/controllers/auth_pages.rs +++ b/src/controllers/auth_pages.rs @@ -185,6 +185,62 @@ fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result { ) } +/// Resend the email-verification link. Throttled per account (see +/// [`users::Model::verification_resend_wait_secs`]) so it can't be used to spam +/// an inbox, and always returns the same neutral message so it can't be used to +/// probe which addresses are registered. +#[derive(Debug, serde::Deserialize)] +struct ResendVerificationForm { + email: String, +} + +fn resend_verification_view(v: &TeraView, jar: &CookieJar, done: bool) -> Result { + format::view( + v, + "auth/resend_verification.html", + json!({ + "done": done, + "logged_in_admin": false, + "lang": current_lang(jar), + }), + ) +} + +#[debug_handler] +async fn resend_verification_page( + jar: CookieJar, + ViewEngine(v): ViewEngine, +) -> Result { + resend_verification_view(&v, &jar, false) +} + +#[debug_handler] +async fn resend_verification( + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, + Form(form): Form, +) -> Result { + // Resend only for a real, still-unverified account that is past its cooldown. + // Anything else (unknown email, already verified, too soon) silently does + // nothing — the response is identical either way. + if let Ok(user) = users::Model::find_by_email(&ctx.db, form.email.trim()).await { + if user.email_verified_at.is_none() && user.verification_resend_wait_secs() == 0 { + match user.into_active_model().set_email_verification_sent(&ctx.db).await { + Ok(user) => { + if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await { + tracing::error!(error = %err, "failed to resend verification email"); + } + } + Err(err) => tracing::error!(error = %err, "failed to refresh verification token"), + } + } else { + tracing::info!("verification resend skipped (already verified or within cooldown)"); + } + } + resend_verification_view(&v, &jar, true) +} + /// Set-password form for accounts created during checkout (and any account that /// has a valid reset token). Reuses the password-reset token machinery. #[derive(Debug, serde::Deserialize)] @@ -276,6 +332,8 @@ pub fn routes() -> Routes { .add("/register", get(register_page)) .add("/register", post(register)) .add("/verify/{token}", get(verify)) + .add("/resend-verification", get(resend_verification_page)) + .add("/resend-verification", post(resend_verification)) .add("/set-password/{token}", get(set_password_page)) .add("/set-password", post(set_password)) .add("/logout", post(logout)) diff --git a/src/models/users.rs b/src/models/users.rs index a06b823..6db5cbc 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -13,6 +13,9 @@ pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model}; pub const MAGIC_LINK_LENGTH: i8 = 32; pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5; +/// Minimum gap between verification-email resends for one account, in seconds. +pub const VERIFICATION_RESEND_COOLDOWN_SECS: i64 = 60; + #[derive(Debug, Deserialize, Serialize)] pub struct LoginParams { pub email: String, @@ -238,6 +241,22 @@ impl Model { self.account_type == "company" } + /// Seconds the user must still wait before another verification email may be + /// sent — 0 means a resend is allowed now. Throttling resends off the last + /// `email_verification_sent_at` keeps the endpoint from being an easy way to + /// spam someone's inbox. + #[must_use] + pub fn verification_resend_wait_secs(&self) -> i64 { + match self.email_verification_sent_at { + Some(sent) => { + let elapsed = + (chrono::Utc::now() - sent.with_timezone(&chrono::Utc)).num_seconds(); + (VERIFICATION_RESEND_COOLDOWN_SECS - elapsed).max(0) + } + None => 0, + } + } + /// Asynchronously creates a user with a password and saves it to the /// database. ///