resend verification mail
This commit is contained in:
@@ -286,6 +286,11 @@ set-password-submit = Set password
|
|||||||
set-password-invalid = This link is invalid or has expired.
|
set-password-invalid = This link is invalid or has expired.
|
||||||
set-password-weak = Password must be at least 8 characters.
|
set-password-weak = Password must be at least 8 characters.
|
||||||
set-password-mismatch = Passwords don't match.
|
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-title = Thank you for your order!
|
||||||
order-confirmed-sub = We have received your order.
|
order-confirmed-sub = We have received your order.
|
||||||
order-number = Order number
|
order-number = Order number
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ set-password-submit = Nastaviť heslo
|
|||||||
set-password-invalid = Odkaz je neplatný alebo vypršal.
|
set-password-invalid = Odkaz je neplatný alebo vypršal.
|
||||||
set-password-weak = Heslo musí mať aspoň 8 znakov.
|
set-password-weak = Heslo musí mať aspoň 8 znakov.
|
||||||
set-password-mismatch = Heslá sa nezhodujú.
|
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-title = Ďakujeme za objednávku!
|
||||||
order-confirmed-sub = Vašu objednávku sme prijali.
|
order-confirmed-sub = Vašu objednávku sme prijali.
|
||||||
order-number = Číslo objednávky
|
order-number = Číslo objednávky
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
|
|
||||||
{% if error == "unverified" %}
|
{% if error == "unverified" %}
|
||||||
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
|
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<a href="/resend-verification" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="login-resend", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
{% elif error %}
|
{% elif error %}
|
||||||
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
37
assets/views/auth/resend_verification.html
Normal file
37
assets/views/auth/resend_verification.html
Normal file
@@ -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 %}
|
||||||
|
<div class="mx-auto mt-8 max-w-sm">
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if done %}
|
||||||
|
<div class="mt-3 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||||
|
{{ t(key="resend-verification-done", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<a href="/login" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="resend-verification-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<form method="post" action="/resend-verification" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="login-email", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="resend-verification-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -185,6 +185,62 @@ fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result<Response> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Response> {
|
||||||
|
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<TeraView>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
resend_verification_view(&v, &jar, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn resend_verification(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<ResendVerificationForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
// 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
|
/// Set-password form for accounts created during checkout (and any account that
|
||||||
/// has a valid reset token). Reuses the password-reset token machinery.
|
/// has a valid reset token). Reuses the password-reset token machinery.
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
@@ -276,6 +332,8 @@ pub fn routes() -> Routes {
|
|||||||
.add("/register", get(register_page))
|
.add("/register", get(register_page))
|
||||||
.add("/register", post(register))
|
.add("/register", post(register))
|
||||||
.add("/verify/{token}", get(verify))
|
.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/{token}", get(set_password_page))
|
||||||
.add("/set-password", post(set_password))
|
.add("/set-password", post(set_password))
|
||||||
.add("/logout", post(logout))
|
.add("/logout", post(logout))
|
||||||
|
|||||||
@@ -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_LENGTH: i8 = 32;
|
||||||
pub const MAGIC_LINK_EXPIRATION_MIN: i8 = 5;
|
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)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct LoginParams {
|
pub struct LoginParams {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
@@ -238,6 +241,22 @@ impl Model {
|
|||||||
self.account_type == "company"
|
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
|
/// Asynchronously creates a user with a password and saves it to the
|
||||||
/// database.
|
/// database.
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user