Add email magic link authentication provider
New pluggable auth provider that sends passwordless sign-in links. Works with domain restriction (same as Google OAuth). Falls back to showing the link in browser when SMTP is not configured (dev mode).
This commit is contained in:
parent
b99ec576ca
commit
e2ab219171
8 changed files with 542 additions and 5 deletions
0
auth/email/__init__.py
Normal file
0
auth/email/__init__.py
Normal file
273
auth/email/provider.py
Normal file
273
auth/email/provider.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""
|
||||
Email magic link authentication provider.
|
||||
|
||||
Users enter their email, receive a magic link, click it and they're logged in.
|
||||
No passwords needed. Domain restriction ensures only allowed users can access.
|
||||
|
||||
Email delivery modes:
|
||||
1. SMTP relay (recommended) - configure SMTP_HOST, SMTP_PORT, etc. in .env
|
||||
2. Console mode (development) - link printed to server log, shown in browser
|
||||
"""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
import time
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
current_app,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from auth import AuthProvider
|
||||
from webapp.auth import validate_email_domain
|
||||
from webapp.config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
email_bp = Blueprint("email_auth", __name__)
|
||||
|
||||
# SVG envelope icon for the login button
|
||||
_EMAIL_ICON_HTML = (
|
||||
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" '
|
||||
'stroke="currentColor" stroke-width="2" stroke-linecap="round" '
|
||||
'stroke-linejoin="round">'
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/>'
|
||||
'<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
|
||||
def _get_serializer() -> URLSafeTimedSerializer:
|
||||
"""Create token serializer using the app secret key."""
|
||||
return URLSafeTimedSerializer(Config.SECRET_KEY, salt="email-magic-link")
|
||||
|
||||
|
||||
def _generate_magic_token(email: str) -> str:
|
||||
"""Generate a signed, time-limited token containing the email."""
|
||||
s = _get_serializer()
|
||||
return s.dumps({"email": email.lower(), "t": int(time.time())})
|
||||
|
||||
|
||||
def _verify_magic_token(token: str, max_age_seconds: int = 900) -> str | None:
|
||||
"""Verify magic link token. Returns email if valid, None otherwise.
|
||||
|
||||
Args:
|
||||
token: The signed token from the magic link URL.
|
||||
max_age_seconds: Token validity period (default 15 minutes).
|
||||
|
||||
Returns:
|
||||
Email address if token is valid, None otherwise.
|
||||
"""
|
||||
s = _get_serializer()
|
||||
try:
|
||||
data = s.loads(token, max_age=max_age_seconds)
|
||||
return data.get("email")
|
||||
except SignatureExpired:
|
||||
logger.warning("Magic link token expired")
|
||||
return None
|
||||
except BadSignature:
|
||||
logger.warning("Invalid magic link token")
|
||||
return None
|
||||
|
||||
|
||||
def _send_magic_email(email: str, magic_url: str) -> bool:
|
||||
"""Send magic link email via SMTP relay.
|
||||
|
||||
Returns True if sent successfully, False otherwise.
|
||||
"""
|
||||
smtp_host = Config.SMTP_HOST
|
||||
if not smtp_host:
|
||||
return False
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = f"Sign in to {Config.INSTANCE_NAME}"
|
||||
msg["From"] = Config.SMTP_FROM
|
||||
msg["To"] = email
|
||||
|
||||
text_body = (
|
||||
f"Sign in to {Config.INSTANCE_NAME}\n\n"
|
||||
f"Click the link below to sign in:\n{magic_url}\n\n"
|
||||
f"This link expires in 15 minutes.\n"
|
||||
f"If you didn't request this, ignore this email."
|
||||
)
|
||||
|
||||
html_body = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 20px;">
|
||||
<h2 style="color: #1a1a2e;">Sign in to {Config.INSTANCE_NAME}</h2>
|
||||
<p>Click the button below to sign in:</p>
|
||||
<p style="text-align: center; margin: 30px 0;">
|
||||
<a href="{magic_url}"
|
||||
style="background: #4361ee; color: white; padding: 12px 32px;
|
||||
text-decoration: none; border-radius: 6px; font-weight: 500;">
|
||||
Sign In
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
This link expires in 15 minutes.<br>
|
||||
If you didn't request this, ignore this email.
|
||||
</p>
|
||||
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #999; font-size: 12px;">
|
||||
Or copy and paste this URL into your browser:<br>
|
||||
<code style="word-break: break-all;">{magic_url}</code>
|
||||
</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
msg.attach(MIMEText(text_body, "plain"))
|
||||
msg.attach(MIMEText(html_body, "html"))
|
||||
|
||||
try:
|
||||
smtp_port = Config.SMTP_PORT
|
||||
use_tls = Config.SMTP_USE_TLS
|
||||
|
||||
if smtp_port == 465:
|
||||
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10)
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_host, smtp_port, timeout=10)
|
||||
if use_tls:
|
||||
server.starttls()
|
||||
|
||||
smtp_user = Config.SMTP_USER
|
||||
smtp_password = Config.SMTP_PASSWORD
|
||||
if smtp_user and smtp_password:
|
||||
server.login(smtp_user, smtp_password)
|
||||
|
||||
server.sendmail(Config.SMTP_FROM, [email], msg.as_string())
|
||||
server.quit()
|
||||
logger.info("Magic link email sent to %s via SMTP", email)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to send magic link email to %s: %s", email, e)
|
||||
return False
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
|
||||
@email_bp.route("/login/email")
|
||||
def login_email_form():
|
||||
"""Show email input form."""
|
||||
return render_template(
|
||||
"login_magic_link.html",
|
||||
allowed_domain=Config.ALLOWED_DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
@email_bp.route("/login/email/send", methods=["POST"])
|
||||
def send_magic_link():
|
||||
"""Validate email domain and send magic link."""
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
|
||||
if not email:
|
||||
flash("Please enter your email address.", "error")
|
||||
return redirect(url_for("email_auth.login_email_form"))
|
||||
|
||||
if not validate_email_domain(email):
|
||||
flash(
|
||||
f"Only @{Config.ALLOWED_DOMAIN} email addresses are allowed.",
|
||||
"error",
|
||||
)
|
||||
return redirect(url_for("email_auth.login_email_form"))
|
||||
|
||||
# Generate magic link
|
||||
token = _generate_magic_token(email)
|
||||
magic_url = url_for("email_auth.verify_magic_link", token=token, _external=True)
|
||||
|
||||
# Try SMTP first, fall back to console mode
|
||||
smtp_sent = _send_magic_email(email, magic_url)
|
||||
|
||||
if smtp_sent:
|
||||
flash("Check your email for the sign-in link.", "info")
|
||||
return render_template(
|
||||
"login_magic_link_sent.html",
|
||||
email=email,
|
||||
console_mode=False,
|
||||
)
|
||||
else:
|
||||
# Console/development mode - show link directly
|
||||
logger.info("MAGIC LINK for %s: %s", email, magic_url)
|
||||
return render_template(
|
||||
"login_magic_link_sent.html",
|
||||
email=email,
|
||||
magic_url=magic_url,
|
||||
console_mode=True,
|
||||
)
|
||||
|
||||
|
||||
@email_bp.route("/login/email/verify/<token>")
|
||||
def verify_magic_link(token: str):
|
||||
"""Verify magic link token and log user in."""
|
||||
email = _verify_magic_token(token)
|
||||
|
||||
if not email:
|
||||
flash("This sign-in link has expired or is invalid. Please try again.", "error")
|
||||
return redirect(url_for("email_auth.login_email_form"))
|
||||
|
||||
# Double-check domain (in case config changed since token was issued)
|
||||
if not validate_email_domain(email):
|
||||
flash("Your email is no longer authorized.", "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# Set session (shared contract across all auth providers)
|
||||
name = email.split("@")[0].replace(".", " ").title()
|
||||
session["user"] = {
|
||||
"email": email,
|
||||
"name": name,
|
||||
"picture": "",
|
||||
}
|
||||
|
||||
logger.info("User logged in via magic link: %s", email)
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
# --- Provider class ---
|
||||
|
||||
|
||||
class EmailAuthProvider(AuthProvider):
|
||||
"""Email magic link authentication provider."""
|
||||
|
||||
def get_name(self) -> str:
|
||||
return "email"
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
return "Email"
|
||||
|
||||
def get_blueprint(self) -> Blueprint:
|
||||
return email_bp
|
||||
|
||||
def get_login_button(self) -> dict:
|
||||
domain = Config.ALLOWED_DOMAIN
|
||||
subtitle = f'For <strong>@{domain}</strong> email addresses.' if domain else ""
|
||||
return {
|
||||
"text": "Sign in with Email",
|
||||
"url": "/login/email",
|
||||
"icon_html": _EMAIL_ICON_HTML,
|
||||
"subtitle": subtitle,
|
||||
"order": 20,
|
||||
"css_class": "btn-email",
|
||||
"visible": True,
|
||||
}
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Available when allowed_domain is configured."""
|
||||
return bool(Config.ALLOWED_DOMAIN)
|
||||
|
||||
def init_app(self, app) -> None:
|
||||
"""No additional initialization needed."""
|
||||
pass
|
||||
|
||||
|
||||
# Module-level provider instance for auto-discovery
|
||||
provider = EmailAuthProvider()
|
||||
|
|
@ -32,11 +32,15 @@ deployment:
|
|||
branch: "main"
|
||||
|
||||
# --- Authentication ---
|
||||
# At minimum, set allowed_domain and webapp_secret_key.
|
||||
# Email magic link auth works out of the box (no external service needed).
|
||||
# Google OAuth is optional - add credentials to enable it.
|
||||
auth:
|
||||
allowed_domain: "" # Google OAuth domain (e.g., "acme.com")
|
||||
allowed_domain: "" # Email domain for login (e.g., "acme.com")
|
||||
webapp_secret_key: "${WEBAPP_SECRET_KEY}"
|
||||
# Optional: Google OAuth (if not set, only email magic link is available)
|
||||
google_client_id: "${GOOGLE_CLIENT_ID}"
|
||||
google_client_secret: "${GOOGLE_CLIENT_SECRET}"
|
||||
webapp_secret_key: "${WEBAPP_SECRET_KEY}"
|
||||
|
||||
# --- Data source ---
|
||||
data_source:
|
||||
|
|
@ -46,11 +50,16 @@ data_source:
|
|||
stack_url: "" # e.g., "https://connection.keboola.com"
|
||||
project_id: ""
|
||||
|
||||
# --- Email (optional, for password auth) ---
|
||||
# --- Email delivery (optional, for magic link auth) ---
|
||||
# Without SMTP, magic links are shown directly in browser (development mode).
|
||||
# For production, configure any SMTP relay (Gmail, Mailgun, SendGrid SMTP, etc.)
|
||||
email:
|
||||
from_address: "noreply@example.com"
|
||||
from_name: "AI Data Analyst"
|
||||
sendgrid_api_key: "${SENDGRID_API_KEY}"
|
||||
smtp_host: "${SMTP_HOST}" # e.g., "smtp.gmail.com"
|
||||
smtp_port: 587 # 587 for STARTTLS, 465 for SSL
|
||||
smtp_user: "${SMTP_USER}"
|
||||
smtp_password: "${SMTP_PASSWORD}"
|
||||
|
||||
# --- Desktop app (optional) ---
|
||||
desktop:
|
||||
|
|
|
|||
95
tests/test_email_auth.py
Normal file
95
tests/test_email_auth.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Tests for the email magic link authentication provider."""
|
||||
|
||||
import pytest
|
||||
|
||||
from auth.email.provider import (
|
||||
EmailAuthProvider,
|
||||
_generate_magic_token,
|
||||
_verify_magic_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMagicTokens:
|
||||
"""Test magic link token generation and verification."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_env(self, monkeypatch):
|
||||
"""Set required env vars for Config."""
|
||||
monkeypatch.setenv("WEBAPP_SECRET_KEY", "test-secret-key-for-tokens")
|
||||
|
||||
def test_generate_and_verify_token(self):
|
||||
"""Valid token should return the email."""
|
||||
token = _generate_magic_token("user@acme.com")
|
||||
email = _verify_magic_token(token, max_age_seconds=60)
|
||||
assert email == "user@acme.com"
|
||||
|
||||
def test_token_normalizes_email(self):
|
||||
"""Email should be lowercased in the token."""
|
||||
token = _generate_magic_token("User@ACME.com")
|
||||
email = _verify_magic_token(token, max_age_seconds=60)
|
||||
assert email == "user@acme.com"
|
||||
|
||||
def test_expired_token_returns_none(self):
|
||||
"""Expired token should return None."""
|
||||
import time
|
||||
token = _generate_magic_token("user@acme.com")
|
||||
time.sleep(2)
|
||||
email = _verify_magic_token(token, max_age_seconds=1)
|
||||
assert email is None
|
||||
|
||||
def test_invalid_token_returns_none(self):
|
||||
"""Tampered token should return None."""
|
||||
email = _verify_magic_token("not-a-valid-token", max_age_seconds=60)
|
||||
assert email is None
|
||||
|
||||
def test_empty_token_returns_none(self):
|
||||
"""Empty token should return None."""
|
||||
email = _verify_magic_token("", max_age_seconds=60)
|
||||
assert email is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEmailAuthProvider:
|
||||
"""Test the EmailAuthProvider class."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_env(self, monkeypatch):
|
||||
"""Set required env vars."""
|
||||
monkeypatch.setenv("WEBAPP_SECRET_KEY", "test-secret")
|
||||
|
||||
def test_provider_name(self):
|
||||
provider = EmailAuthProvider()
|
||||
assert provider.get_name() == "email"
|
||||
|
||||
def test_provider_display_name(self):
|
||||
provider = EmailAuthProvider()
|
||||
assert provider.get_display_name() == "Email"
|
||||
|
||||
def test_login_button_properties(self, monkeypatch):
|
||||
# Reload config with allowed domain set
|
||||
monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "acme.com")
|
||||
provider = EmailAuthProvider()
|
||||
button = provider.get_login_button()
|
||||
assert button["text"] == "Sign in with Email"
|
||||
assert button["url"] == "/login/email"
|
||||
assert button["visible"] is True
|
||||
assert button["order"] == 20
|
||||
assert "btn-email" in button["css_class"]
|
||||
assert "acme.com" in button["subtitle"]
|
||||
|
||||
def test_provider_available_with_domain(self, monkeypatch):
|
||||
monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "acme.com")
|
||||
provider = EmailAuthProvider()
|
||||
assert provider.is_available() is True
|
||||
|
||||
def test_provider_unavailable_without_domain(self, monkeypatch):
|
||||
monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "")
|
||||
provider = EmailAuthProvider()
|
||||
assert provider.is_available() is False
|
||||
|
|
@ -57,7 +57,17 @@ class Config:
|
|||
os.environ.get("PASSWORD_USERS_FILE", "/data/auth/password_users.json")
|
||||
)
|
||||
|
||||
# SendGrid email service
|
||||
# SMTP email delivery (for magic link auth)
|
||||
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||
SMTP_FROM = os.environ.get("SMTP_FROM",
|
||||
os.environ.get("SMTP_USER",
|
||||
_get(_instance, "email", "from_address", default="noreply@example.com")))
|
||||
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
|
||||
|
||||
# SendGrid email service (legacy, for password auth)
|
||||
SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY", "")
|
||||
EMAIL_FROM_ADDRESS = os.environ.get("EMAIL_FROM_ADDRESS",
|
||||
_get(_instance, "email", "from_address", default="noreply@example.com"))
|
||||
|
|
|
|||
|
|
@ -1771,6 +1771,33 @@ a.slack-badge:hover {
|
|||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Email magic link button styling */
|
||||
.login-page .btn-email {
|
||||
background-color: #4361ee;
|
||||
color: white;
|
||||
border: 1px solid #3a56d4;
|
||||
padding: 14px 28px;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-page .btn-email:hover {
|
||||
background-color: #3a56d4;
|
||||
box-shadow: 0 2px 8px rgba(67, 97, 238, 0.3);
|
||||
}
|
||||
|
||||
.login-page .btn-email:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.login-page .btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
|
|
|
|||
59
webapp/templates/login_magic_link.html
Normal file
59
webapp/templates/login_magic_link.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{% extends "base_login.html" %}
|
||||
|
||||
{% block title %}Sign in with Email - {{ config.INSTANCE_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-page">
|
||||
<div class="login-split">
|
||||
<div class="login-features">
|
||||
<div class="features-content">
|
||||
<h1 class="features-title">{{ config.INSTANCE_NAME }}</h1>
|
||||
<p class="features-subtitle">
|
||||
Enter your email address and we'll send you a sign-in link.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-card-wrapper">
|
||||
<div class="login-card">
|
||||
<h2>Sign In with Email</h2>
|
||||
<p class="login-description">
|
||||
We'll send a magic link to your email. Click it to sign in — no password needed.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('email_auth.send_magic_link') }}" class="login-form" style="width: 100%; max-width: 320px;">
|
||||
<div class="form-group" style="margin-bottom: 16px;">
|
||||
<label for="email" style="display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px;">Email Address</label>
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="you@{{ allowed_domain or 'company.com' }}"
|
||||
required
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
style="width: 100%; padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 15px; box-sizing: border-box;">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">
|
||||
Send Sign-In Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if allowed_domain %}
|
||||
<p class="login-note">
|
||||
For <strong>@{{ allowed_domain }}</strong> email addresses.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-secondary" style="width: 100%; max-width: 280px;">
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
64
webapp/templates/login_magic_link_sent.html
Normal file
64
webapp/templates/login_magic_link_sent.html
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{% extends "base_login.html" %}
|
||||
|
||||
{% block title %}Check Your Email - {{ config.INSTANCE_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-page">
|
||||
<div class="login-split">
|
||||
<div class="login-features">
|
||||
<div class="features-content">
|
||||
<h1 class="features-title">{{ config.INSTANCE_NAME }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-card-wrapper">
|
||||
<div class="login-card">
|
||||
{% if console_mode %}
|
||||
<h2>Development Mode</h2>
|
||||
<p class="login-description">
|
||||
No SMTP server is configured. Use the link below to sign in.
|
||||
</p>
|
||||
|
||||
<div style="background: #f0f4ff; border: 1px solid #c7d2fe; border-radius: 8px; padding: 16px; margin: 16px 0; word-break: break-all;">
|
||||
<p style="font-size: 12px; color: #666; margin: 0 0 8px 0;">Magic link for <strong>{{ email }}</strong>:</p>
|
||||
<a href="{{ magic_url }}" style="font-size: 14px; color: #4361ee;">{{ magic_url }}</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 13px; color: #888; margin-top: 12px;">
|
||||
To enable email delivery, configure SMTP settings in <code>.env</code>:
|
||||
<code>SMTP_HOST</code>, <code>SMTP_PORT</code>, <code>SMTP_USER</code>, <code>SMTP_PASSWORD</code>
|
||||
</p>
|
||||
|
||||
{% else %}
|
||||
<h2>Check Your Email</h2>
|
||||
<p class="login-description">
|
||||
We sent a sign-in link to <strong>{{ email }}</strong>.
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#4361ee" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
Click the link in the email to sign in. The link expires in 15 minutes.
|
||||
</p>
|
||||
<p style="color: #999; font-size: 13px;">
|
||||
Don't see it? Check your spam folder.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="divider">
|
||||
<span>or</span>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('email_auth.login_email_form') }}" class="btn btn-secondary" style="width: 100%; max-width: 280px;">
|
||||
Try a different email
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue