email verification
This commit is contained in:
1 parent
5c7a498af6
commit
54fa80f06e
14 files changed
+363
-5
No files matched your search
@@ -85,9 +85,11 @@ All variables are read from `.env` at startup. Copy `.env.example` as a starting
|
|||||||
| `MAIL_SERVER` | No | _(empty)_ | SMTP server hostname. Email features are disabled if left blank. |
|
| `MAIL_SERVER` | No | _(empty)_ | SMTP server hostname. Email features are disabled if left blank. |
|
||||||
| `MAIL_PORT` | No | `587` | SMTP port. |
|
| `MAIL_PORT` | No | `587` | SMTP port. |
|
||||||
| `MAIL_USE_TLS` | No | `true` | Set to `false` to disable STARTTLS. |
|
| `MAIL_USE_TLS` | No | `true` | Set to `false` to disable STARTTLS. |
|
||||||
|
| `MAIL_USE_SSL` | No | `false` | Set to `true` for implicit TLS SMTP, commonly port `465`. |
|
||||||
| `MAIL_USERNAME` | No | _(empty)_ | SMTP username / API key. |
|
| `MAIL_USERNAME` | No | _(empty)_ | SMTP username / API key. |
|
||||||
| `MAIL_PASSWORD` | No | _(empty)_ | SMTP password / API key secret. |
|
| `MAIL_PASSWORD` | No | _(empty)_ | SMTP password / API key secret. |
|
||||||
| `MAIL_DEFAULT_SENDER` | No | `noreply@myboker.org` | From address on outgoing mail. |
|
| `MAIL_DEFAULT_SENDER` | No | `noreply@myboker.org` | From address on outgoing mail. |
|
||||||
|
| `MAIL_SEND_TIMEOUT` | No | `5` | Maximum seconds to wait on an SMTP send attempt. |
|
||||||
| `FLASK_DEBUG` | No | `0` | Set to `1` to enable the Flask reloader and debugger. |
|
| `FLASK_DEBUG` | No | `0` | Set to `1` to enable the Flask reloader and debugger. |
|
||||||
|
|
||||||
## Running tests
|
## Running tests
|
||||||
|
|||||||
+10
-2
@@ -36,10 +36,18 @@ def create_app(config_overrides: dict | None = None) -> Flask:
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_globals() -> dict:
|
def inject_globals() -> dict:
|
||||||
|
uid = current_user_id()
|
||||||
|
email_verified = None
|
||||||
|
if uid and db is not None and database_extensions_available():
|
||||||
|
from .db_models import User
|
||||||
|
user = db.session.get(User, uid)
|
||||||
|
if user is not None:
|
||||||
|
email_verified = user.email_verified_at is not None
|
||||||
return {
|
return {
|
||||||
"app_version": app.config["APP_VERSION"],
|
"app_version": app.config["APP_VERSION"],
|
||||||
"current_user_id": current_user_id(),
|
"current_user_id": uid,
|
||||||
"is_logged_in": is_logged_in(),
|
"is_logged_in": uid is not None,
|
||||||
|
"email_verified": email_verified,
|
||||||
}
|
}
|
||||||
|
|
||||||
app.register_blueprint(public_bp)
|
app.register_blueprint(public_bp)
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ def verify_reset_token(token: str, max_age: int = 3600) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def generate_email_verification_token(user_id: str) -> str:
|
||||||
|
return _serializer().dumps(user_id, salt="email-verify")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_email_verification_token(token: str, max_age: int = 86400) -> str | None:
|
||||||
|
try:
|
||||||
|
return _serializer().loads(token, salt="email-verify", max_age=max_age)
|
||||||
|
except (SignatureExpired, BadSignature):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def generate_invite_token(league_id: str, email: str, role: str, invited_by_user_id: str) -> str:
|
def generate_invite_token(league_id: str, email: str, role: str, invited_by_user_id: str) -> str:
|
||||||
return _serializer().dumps(
|
return _serializer().dumps(
|
||||||
{"league_id": league_id, "email": email, "role": role, "invited_by": invited_by_user_id},
|
{"league_id": league_id, "email": email, "role": role, "invited_by": invited_by_user_id},
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class Config:
|
|||||||
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "")
|
MAIL_SERVER: str = os.getenv("MAIL_SERVER", "")
|
||||||
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
|
||||||
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "true").lower() in ("true", "1", "yes")
|
MAIL_USE_TLS: bool = os.getenv("MAIL_USE_TLS", "true").lower() in ("true", "1", "yes")
|
||||||
|
MAIL_USE_SSL: bool = os.getenv("MAIL_USE_SSL", "false").lower() in ("true", "1", "yes")
|
||||||
MAIL_USERNAME: str | None = os.getenv("MAIL_USERNAME") or None
|
MAIL_USERNAME: str | None = os.getenv("MAIL_USERNAME") or None
|
||||||
MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None
|
MAIL_PASSWORD: str | None = os.getenv("MAIL_PASSWORD") or None
|
||||||
MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org")
|
MAIL_DEFAULT_SENDER: str = os.getenv("MAIL_DEFAULT_SENDER", "noreply@myboker.org")
|
||||||
|
MAIL_SEND_TIMEOUT: float = float(os.getenv("MAIL_SEND_TIMEOUT", "5"))
|
||||||
|
|
||||||
|
|
||||||
class ProductionConfig(Config):
|
class ProductionConfig(Config):
|
||||||
|
|||||||
+48
-2
@@ -1,12 +1,43 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import socket
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask_mail import Message
|
from flask_mail import Message
|
||||||
|
|
||||||
from .extensions import mail
|
from .extensions import mail
|
||||||
|
|
||||||
|
|
||||||
|
class MailNotConfiguredError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _mail_socket_timeout() -> Iterator[None]:
|
||||||
|
timeout = current_app.config.get("MAIL_SEND_TIMEOUT")
|
||||||
|
if timeout is None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
previous_timeout = socket.getdefaulttimeout()
|
||||||
|
socket.setdefaulttimeout(float(timeout))
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
socket.setdefaulttimeout(previous_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _send(msg: Message) -> None:
|
||||||
|
if not str(current_app.config.get("MAIL_SERVER", "")).strip():
|
||||||
|
raise MailNotConfiguredError("MAIL_SERVER is not configured.")
|
||||||
|
|
||||||
|
with _mail_socket_timeout():
|
||||||
|
mail.send(msg)
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset(to_email: str, reset_url: str) -> None:
|
def send_password_reset(to_email: str, reset_url: str) -> None:
|
||||||
msg = Message(
|
msg = Message(
|
||||||
subject="Reset your myboker.org password",
|
subject="Reset your myboker.org password",
|
||||||
@@ -19,7 +50,22 @@ def send_password_reset(to_email: str, reset_url: str) -> None:
|
|||||||
),
|
),
|
||||||
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
)
|
)
|
||||||
mail.send(msg)
|
_send(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_email_verification(to_email: str, verify_url: str) -> None:
|
||||||
|
msg = Message(
|
||||||
|
subject="Verify your myboker.org email address",
|
||||||
|
recipients=[to_email],
|
||||||
|
body=(
|
||||||
|
f"Thanks for signing up for myboker.org!\n\n"
|
||||||
|
f"Please verify your email address by clicking the link below:\n\n"
|
||||||
|
f"{verify_url}\n\n"
|
||||||
|
f"This link expires in 24 hours. If you did not create an account, you can ignore this email."
|
||||||
|
),
|
||||||
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
|
)
|
||||||
|
_send(msg)
|
||||||
|
|
||||||
|
|
||||||
def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str) -> None:
|
def send_league_invite(to_email: str, league_name: str, invite_url: str, invited_by_email: str) -> None:
|
||||||
@@ -34,4 +80,4 @@ def send_league_invite(to_email: str, league_name: str, invite_url: str, invited
|
|||||||
),
|
),
|
||||||
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
sender=current_app.config.get("MAIL_DEFAULT_SENDER"),
|
||||||
)
|
)
|
||||||
mail.send(msg)
|
_send(msg)
|
||||||
+76
-1
@@ -7,6 +7,7 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for
|
|||||||
|
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
current_user_id,
|
current_user_id,
|
||||||
|
generate_email_verification_token,
|
||||||
generate_invite_token,
|
generate_invite_token,
|
||||||
generate_reset_token,
|
generate_reset_token,
|
||||||
hash_password,
|
hash_password,
|
||||||
@@ -14,6 +15,7 @@ from ..auth import (
|
|||||||
log_user_out,
|
log_user_out,
|
||||||
login_required,
|
login_required,
|
||||||
normalize_email,
|
normalize_email,
|
||||||
|
verify_email_verification_token,
|
||||||
verify_invite_token,
|
verify_invite_token,
|
||||||
verify_password,
|
verify_password,
|
||||||
verify_reset_token,
|
verify_reset_token,
|
||||||
@@ -67,7 +69,16 @@ def register():
|
|||||||
user = create_user(email, password)
|
user = create_user(email, password)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
log_user_in(user.id)
|
log_user_in(user.id)
|
||||||
flash("Account created.", "success")
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
from ..emails import send_email_verification
|
||||||
|
token = generate_email_verification_token(user.id)
|
||||||
|
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
|
||||||
|
verify_url = f"{base_url}{url_for('account.verify_email', token=token)}"
|
||||||
|
send_email_verification(user.email, verify_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
flash("Account created. Check your email to verify your address.", "success")
|
||||||
return redirect(url_for("leagues.new"))
|
return redirect(url_for("leagues.new"))
|
||||||
|
|
||||||
return render_template("account_register.html", form=form)
|
return render_template("account_register.html", form=form)
|
||||||
@@ -343,3 +354,67 @@ def accept_invite(token):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success")
|
flash(f"Welcome to {league.name}! You joined as {data['role']}.", "success")
|
||||||
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
|
return redirect(url_for("leagues.dashboard", league_ref=league.url_ref))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.get("/verify-email/<token>")
|
||||||
|
def verify_email(token):
|
||||||
|
user_id = verify_email_verification_token(token)
|
||||||
|
if user_id is None:
|
||||||
|
flash("That verification link is invalid or has expired.", "error")
|
||||||
|
return redirect(url_for("account.settings") if current_user_id() else url_for("account.login"))
|
||||||
|
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
from ..db_models import User, utc_now
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
flash("That verification link is invalid or has expired.", "error")
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
if user.email_verified_at is not None:
|
||||||
|
flash("Your email address is already verified.", "info")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
user.email_verified_at = utc_now()
|
||||||
|
db.session.commit()
|
||||||
|
flash("Email address verified.", "success")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/resend-verification")
|
||||||
|
@login_required
|
||||||
|
def resend_verification():
|
||||||
|
if not db_ready():
|
||||||
|
flash("Account database is not available.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from ..db_models import User
|
||||||
|
from ..emails import MailNotConfiguredError, send_email_verification
|
||||||
|
|
||||||
|
user = db.session.get(User, current_user_id())
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
if user.email_verified_at is not None:
|
||||||
|
flash("Your email address is already verified.", "info")
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = generate_email_verification_token(user.id)
|
||||||
|
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
|
||||||
|
verify_url = f"{base_url}{url_for('account.verify_email', token=token)}"
|
||||||
|
send_email_verification(user.email, verify_url)
|
||||||
|
flash("Verification email sent. Check your inbox.", "success")
|
||||||
|
except MailNotConfiguredError:
|
||||||
|
flash("Could not send verification email. Please try again later.", "error")
|
||||||
|
except Exception:
|
||||||
|
current_app.logger.exception("Failed to send verification email to %s", user.email)
|
||||||
|
flash("Could not send verification email. Please try again later.", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("account.settings"))
|
||||||
@@ -283,6 +283,43 @@ def send_reset(user_id: str):
|
|||||||
return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1"))
|
return redirect(url_for("site_admin.user_detail", user_id=user_id, show_reset="1"))
|
||||||
|
|
||||||
|
|
||||||
|
@site_admin_bp.post("/users/<user_id>/send-verification")
|
||||||
|
@site_admin_required
|
||||||
|
def send_verification(user_id: str):
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
from ..auth import generate_email_verification_token
|
||||||
|
from ..db_models import User
|
||||||
|
from ..emails import MailNotConfiguredError, send_email_verification
|
||||||
|
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
flash("User not found.", "error")
|
||||||
|
return redirect(url_for("site_admin.users"))
|
||||||
|
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
flash("Cannot send a verification email to a disabled account.", "error")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
if user.email_verified_at is not None:
|
||||||
|
flash("That account's email is already verified.", "error")
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = generate_email_verification_token(user.id)
|
||||||
|
base_url = current_app.config.get("APP_BASE_URL", "").rstrip("/")
|
||||||
|
verify_url = f"{base_url}{url_for('account.verify_email', token=token)}"
|
||||||
|
send_email_verification(user.email, verify_url)
|
||||||
|
flash(f"Verification email sent to {user.email}.", "success")
|
||||||
|
except MailNotConfiguredError:
|
||||||
|
flash("Email not sent (mail not configured).", "error")
|
||||||
|
except Exception:
|
||||||
|
current_app.logger.exception("Failed to send verification email to %s", user.email)
|
||||||
|
flash("Email not sent (mail not configured).", "error")
|
||||||
|
|
||||||
|
return redirect(url_for("site_admin.user_detail", user_id=user_id))
|
||||||
|
|
||||||
|
|
||||||
@site_admin_bp.post("/users/<user_id>/disable")
|
@site_admin_bp.post("/users/<user_id>/disable")
|
||||||
@site_admin_required
|
@site_admin_required
|
||||||
def disable_user(user_id: str):
|
def disable_user(user_id: str):
|
||||||
|
|||||||
@@ -372,6 +372,7 @@
|
|||||||
.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
|
.adm-pill--manager { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
|
||||||
.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
|
.adm-pill--viewer { background: var(--surface-raised); color: var(--faintest); border: 1px solid var(--border); }
|
||||||
.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); }
|
.adm-pill--archived{ background: var(--surface-raised); color: var(--faintest-2); border: 1px solid var(--border); }
|
||||||
|
.adm-pill--warn { background: var(--warn-tint); color: var(--warn); border: 1px solid var(--warn-tint-bd); }
|
||||||
|
|
||||||
/* ---- search bar ---- */
|
/* ---- search bar ---- */
|
||||||
.adm-search {
|
.adm-search {
|
||||||
|
|||||||
@@ -3359,3 +3359,44 @@ select.control { cursor: pointer; }
|
|||||||
.card-menu__item:hover { background: var(--field); color: var(--text); }
|
.card-menu__item:hover { background: var(--field); color: var(--text); }
|
||||||
.card-menu__item--warn { color: var(--neg); }
|
.card-menu__item--warn { color: var(--neg); }
|
||||||
.card-menu__item--warn:hover { background: var(--neg-tint); color: var(--neg); }
|
.card-menu__item--warn:hover { background: var(--neg-tint); color: var(--neg); }
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
EMAIL VERIFICATION
|
||||||
|
================================================================ */
|
||||||
|
.verify-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--warn-tint);
|
||||||
|
border-bottom: 1px solid var(--warn-tint-bd);
|
||||||
|
font: 400 13px/1.4 var(--font-ui);
|
||||||
|
color: var(--warn);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.verify-banner__btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
font: 400 13px/1.4 var(--font-ui);
|
||||||
|
color: var(--warn);
|
||||||
|
text-decoration: underline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.verify-banner__btn:hover { opacity: .8; }
|
||||||
|
|
||||||
|
.verify-status {
|
||||||
|
font: 400 12px/1 var(--font-ui);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.verify-status--ok { color: var(--pos); }
|
||||||
|
.verify-status--warn { color: var(--warn); }
|
||||||
|
|
||||||
|
.link-btn {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.link-btn:hover { opacity: .8; }
|
||||||
@@ -16,12 +16,29 @@
|
|||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
|
|
||||||
|
{% if not user.email_verified_at %}
|
||||||
|
<form class="panel form-card" method="post" action="{{ url_for('account.resend_verification') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow" style="color:var(--warn);">Verification</p>
|
||||||
|
<h2 class="panel__title" style="margin-top:4px;">Email not verified</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted-text" style="margin:0;">Your email address hasn't been verified yet. Check your inbox or resend the email below.</p>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn--primary" type="submit">Resend verification email</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form class="panel form-card" method="post" action="{{ url_for('account.update_email') }}">
|
<form class="panel form-card" method="post" action="{{ url_for('account.update_email') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Profile</p>
|
<p class="eyebrow">Profile</p>
|
||||||
<h2 class="panel__title" style="margin-top:4px;">Email address</h2>
|
<h2 class="panel__title" style="margin-top:4px;">Email address</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{% if user.email_verified_at %}
|
||||||
|
<p class="verify-status verify-status--ok">Verified on {{ user.email_verified_at.strftime('%b %d, %Y') }}</p>
|
||||||
|
{% endif %}
|
||||||
<label>
|
<label>
|
||||||
<span>Email</span>
|
<span>Email</span>
|
||||||
<input type="email" name="email" value="{{ user.email }}" required autocomplete="email">
|
<input type="email" name="email" value="{{ user.email }}" required autocomplete="email">
|
||||||
|
|||||||
@@ -30,6 +30,17 @@
|
|||||||
<dt>Last login</dt>
|
<dt>Last login</dt>
|
||||||
<dd>{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}<span style="color:var(--faintest)">Never</span>{% endif %}</dd>
|
<dd>{% if user.last_login_at %}{{ user.last_login_at.strftime('%b %d, %Y at %H:%M UTC') }}{% else %}<span style="color:var(--faintest)">Never</span>{% endif %}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="adm-kv-row">
|
||||||
|
<dt>Email verified</dt>
|
||||||
|
<dd>
|
||||||
|
{% if user.email_verified_at %}
|
||||||
|
<span class="adm-pill adm-pill--active">Yes</span>
|
||||||
|
<span style="color:var(--faintest);font-size:12px;margin-left:6px;">{{ user.email_verified_at.strftime('%b %d, %Y at %H:%M UTC') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="adm-pill adm-pill--warn">No</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
<div class="adm-kv-row">
|
<div class="adm-kv-row">
|
||||||
<dt>Status</dt>
|
<dt>Status</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -82,6 +93,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="adm-section" style="margin-bottom:20px;">
|
||||||
|
<div class="adm-section__title" style="margin-bottom:8px;">Email verification</div>
|
||||||
|
<div class="adm-actions">
|
||||||
|
<form class="adm-inline-form" method="post" action="{{ url_for('site_admin.send_verification', user_id=user.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="adm-btn adm-btn--ghost" type="submit"{% if user.disabled_at or user.email_verified_at %} disabled title="{% if user.email_verified_at %}Already verified{% else %}Account is disabled{% endif %}"{% endif %}>
|
||||||
|
Send verification email
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="adm-section" style="margin-bottom:20px;">
|
<div class="adm-section" style="margin-bottom:20px;">
|
||||||
<div class="adm-section__title" style="margin-bottom:8px;">Set password directly</div>
|
<div class="adm-section__title" style="margin-bottom:8px;">Set password directly</div>
|
||||||
<form class="adm-pass-form" method="post" action="{{ url_for('site_admin.admin_reset_password', user_id=user.id) }}">
|
<form class="adm-pass-form" method="post" action="{{ url_for('site_admin.admin_reset_password', user_id=user.id) }}">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
<a href="{{ url_for('site_admin.user_detail', user_id=user.id) }}">{{ user.email }}</a>
|
||||||
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
|
{% if user.is_site_admin %}<span class="adm-pill adm-pill--admin" style="margin-left:4px;">admin</span>{% endif %}
|
||||||
|
{% if not user.email_verified_at %}<span class="adm-pill adm-pill--warn" style="margin-left:4px;">unverified</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
|
<td class="cell-muted">{{ user.created_at.strftime('%b %d, %Y') }}</td>
|
||||||
<td class="cell-muted">
|
<td class="cell-muted">
|
||||||
|
|||||||
@@ -63,6 +63,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if is_logged_in and email_verified is not none and not email_verified %}
|
||||||
|
<div class="verify-banner">
|
||||||
|
<span>Your email address hasn't been verified.</span>
|
||||||
|
<form method="post" action="{{ url_for('account.resend_verification') }}" style="margin:0;display:inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button class="verify-banner__btn" type="submit">Resend verification email</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<footer class="site-footer" aria-label="Site footer">
|
<footer class="site-footer" aria-label="Site footer">
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import socket
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from flask_mail import Message
|
||||||
|
|
||||||
|
from boker import create_app
|
||||||
|
from boker.db import db
|
||||||
|
from boker.emails import MailNotConfiguredError, send_email_verification
|
||||||
|
from boker.repositories.leagues import create_user
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDeliveryTests(unittest.TestCase):
|
||||||
|
def test_blank_mail_server_fails_before_smtp_send(self):
|
||||||
|
app = create_app({"MAIL_SERVER": "", "TESTING": True})
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
with patch("boker.emails.mail.send") as send:
|
||||||
|
with self.assertRaises(MailNotConfiguredError):
|
||||||
|
send_email_verification("user@example.com", "http://localhost/verify")
|
||||||
|
|
||||||
|
send.assert_not_called()
|
||||||
|
|
||||||
|
def test_smtp_send_uses_configured_socket_timeout(self):
|
||||||
|
app = create_app({"MAIL_SERVER": "smtp.example.com", "MAIL_SEND_TIMEOUT": 1.5, "TESTING": True})
|
||||||
|
msg = Message(
|
||||||
|
subject="Test",
|
||||||
|
recipients=["user@example.com"],
|
||||||
|
body="body",
|
||||||
|
sender="noreply@example.com",
|
||||||
|
)
|
||||||
|
observed_timeout = None
|
||||||
|
|
||||||
|
def capture_timeout(_msg):
|
||||||
|
nonlocal observed_timeout
|
||||||
|
observed_timeout = socket.getdefaulttimeout()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
previous_timeout = socket.getdefaulttimeout()
|
||||||
|
with patch("boker.emails.mail.send", side_effect=capture_timeout):
|
||||||
|
from boker.emails import _send
|
||||||
|
|
||||||
|
_send(msg)
|
||||||
|
|
||||||
|
self.assertEqual(observed_timeout, 1.5)
|
||||||
|
self.assertEqual(socket.getdefaulttimeout(), previous_timeout)
|
||||||
|
|
||||||
|
def test_resend_verification_returns_when_mail_is_disabled(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
db_path = Path(tmpdir) / "test.sqlite3"
|
||||||
|
app = create_app(
|
||||||
|
{
|
||||||
|
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{db_path}",
|
||||||
|
"MAIL_SERVER": "",
|
||||||
|
"TESTING": True,
|
||||||
|
"WTF_CSRF_ENABLED": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
user = create_user("user@example.com", "password123")
|
||||||
|
db.session.commit()
|
||||||
|
user_id = user.id
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
with client.session_transaction() as flask_session:
|
||||||
|
flask_session["user_id"] = user_id
|
||||||
|
|
||||||
|
response = client.post("/account/resend-verification")
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
db.engine.dispose()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], "/account/settings")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user