feat(auth): B-2.3 emails FR + DictIA branding (SMTP Resend)
Rebrand src/services/email.py IN PLACE: French + DictIA + brand gradient (#0062ff/#00bdd8/#00c896) — replaces legacy "Speakr" / #2563eb. Greetings now use user.name with fallback to user.username. Subjects: "Vérifiez votre courriel — DictIA" + "Réinitialiser votre mot de passe — DictIA". SMTP_FROM_NAME defaults to DictIA. Footer points to info@dictia.ca with the Loi 25 tagline. Refonte 4 auth templates IN PLACE pour étendre marketing/base.html : check_email, forgot_password, reset_password, verify_success. Tokens DictIA (brand-navy, brand-bg, grad-bg, shadow-cta), French copy, WCAG patterns (label for, focus-visible:outline-2, role=alert, aria-required, text-brand-navy/70 minimum, NBSP français pour Loi 25 / 24 heures / 1 heure / 8 caractères). Translate inline French flash messages in src/api/auth.py for /verify-email, /resend-verification, /forgot-password, /reset-password. Anti-enumeration fix: forgot_password no longer flashes the cooldown remaining (would leak account existence) — silently skips resend, generic flash unchanged. Cooldown logic in src/services/email.py UNCHANGED (60s — verified by test). config/env.email.example: defaults to Resend SMTP at the top + adds Resend to the provider examples list (preserves Gmail/SendGrid/Mailgun/SES/M365). Tests: tests/test_email_service_dictia.py — 12 tests covering DictIA branding, French copy, display-name fallback, anti-enumeration parity (forgot_password returns identical message for known/unknown emails), 60s cooldown, SMTP-not- configured returns False (no exception), check_email.html extends marketing/base (no var(--text-primary) leaks). Includes Windows manual driver (_run_email_service_dictia_windows.py) since pytest cannot collect on Windows native (fcntl POSIX-only). NO new dependency added (no resend SDK — SMTP via existing _send_email). NO new route added or removed. NO src/auth_extended/ created. NO change to itsdangerous-based token logic. templates/auth/**/*.html already in tailwind.config.js content array (B-2.2). Verified locally on Windows manual driver: 12/12 PASS B-2.3, 9/9 PASS regression on B-2.2 signup, 9/9 PASS regression on B-2.1 ConsentLog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,25 +14,25 @@ ENABLE_EMAIL_VERIFICATION=false
|
|||||||
REQUIRE_EMAIL_VERIFICATION=false
|
REQUIRE_EMAIL_VERIFICATION=false
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# SMTP Configuration
|
# SMTP Configuration (Resend recommended for DictIA — Loi 25 compliant via DKIM/SPF/DMARC)
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
# SMTP server hostname (required for email functionality)
|
# SMTP server hostname (required for email functionality)
|
||||||
# Examples: smtp.gmail.com, smtp.sendgrid.net, smtp.mailgun.org
|
# DictIA default: Resend SMTP relay (https://resend.com)
|
||||||
SMTP_HOST=smtp.gmail.com
|
SMTP_HOST=smtp.resend.com
|
||||||
|
|
||||||
# SMTP server port
|
# SMTP server port
|
||||||
# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 25 (unencrypted)
|
# Common ports: 587 (TLS/STARTTLS), 465 (SSL), 2587 (alt-TLS)
|
||||||
# Default: 587
|
# Default: 587
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
|
||||||
# SMTP authentication username (usually your email address)
|
# SMTP authentication username
|
||||||
SMTP_USERNAME=your-email@gmail.com
|
# For Resend: literal "resend"
|
||||||
|
SMTP_USERNAME=resend
|
||||||
|
|
||||||
# SMTP authentication password
|
# SMTP authentication password
|
||||||
# For Gmail: Use an App Password (not your regular password)
|
# For Resend: an API key from https://resend.com/api-keys (starts with "re_")
|
||||||
# https://support.google.com/accounts/answer/185833
|
SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
SMTP_PASSWORD=your-app-password
|
|
||||||
|
|
||||||
# Use TLS/STARTTLS encryption (recommended for port 587)
|
# Use TLS/STARTTLS encryption (recommended for port 587)
|
||||||
# Default: true
|
# Default: true
|
||||||
@@ -44,17 +44,27 @@ SMTP_USE_TLS=true
|
|||||||
SMTP_USE_SSL=false
|
SMTP_USE_SSL=false
|
||||||
|
|
||||||
# Email address that appears in the "From" field
|
# Email address that appears in the "From" field
|
||||||
# Should be a valid email address, ideally matching your domain
|
# Domain MUST be verified in your Resend dashboard (DKIM + SPF + DMARC)
|
||||||
SMTP_FROM_ADDRESS=noreply@yourdomain.com
|
# Canonical for DictIA: noreply@dictia.ca
|
||||||
|
SMTP_FROM_ADDRESS=noreply@dictia.ca
|
||||||
|
|
||||||
# Display name that appears alongside the from address
|
# Display name that appears alongside the from address
|
||||||
# Default: Speakr
|
# Default: DictIA
|
||||||
SMTP_FROM_NAME=Speakr
|
SMTP_FROM_NAME=DictIA
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
# Provider-Specific Examples
|
# Provider-Specific Examples
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
# --- Resend (recommended for DictIA — TLS, DKIM/SPF/DMARC, Cloudflare-friendly) ---
|
||||||
|
# SMTP_HOST=smtp.resend.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_USE_TLS=true
|
||||||
|
# SMTP_USERNAME=resend
|
||||||
|
# SMTP_PASSWORD=re_xxxxxxxxxxxxxxxxxxxxxxxxxxx # Get from https://resend.com/api-keys
|
||||||
|
# SMTP_FROM_ADDRESS=noreply@dictia.ca # Domain MUST be verified in Resend dashboard
|
||||||
|
# SMTP_FROM_NAME=DictIA
|
||||||
|
|
||||||
# --- Gmail ---
|
# --- Gmail ---
|
||||||
# SMTP_HOST=smtp.gmail.com
|
# SMTP_HOST=smtp.gmail.com
|
||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
@@ -104,6 +114,6 @@ SMTP_FROM_NAME=Speakr
|
|||||||
|
|
||||||
# Security Recommendations:
|
# Security Recommendations:
|
||||||
# - Always use TLS or SSL encryption
|
# - Always use TLS or SSL encryption
|
||||||
# - Use app-specific passwords when available (Gmail, etc.)
|
# - Use app-specific passwords or API keys when available (Resend, Gmail, etc.)
|
||||||
# - Consider using a dedicated email service (SendGrid, Mailgun, SES)
|
# - For DictIA: prefer Resend (DKIM/SPF/DMARC handled, Loi 25-friendly logs in EU)
|
||||||
# - Set a strong SECRET_KEY in your Flask configuration
|
# - Set a strong SECRET_KEY in your Flask configuration
|
||||||
|
|||||||
@@ -546,16 +546,16 @@ def verify_email(token):
|
|||||||
user_id = verify_email_token(token)
|
user_id = verify_email_token(token)
|
||||||
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
flash('The verification link is invalid or has expired.', 'danger')
|
flash('Le lien de vérification est invalide ou expiré.', 'danger')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
flash('User not found.', 'danger')
|
flash('Utilisateur introuvable.', 'danger')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
if user.email_verified:
|
if user.email_verified:
|
||||||
flash('Your email has already been verified.', 'info')
|
flash('Votre courriel a déjà été vérifié.', 'info')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
# Verify the email
|
# Verify the email
|
||||||
@@ -563,7 +563,7 @@ def verify_email(token):
|
|||||||
user.email_verification_token = None # Clear the token
|
user.email_verification_token = None # Clear the token
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return render_template('auth/verify_success.html', title='Email Verified')
|
return render_template('auth/verify_success.html', title='Courriel vérifié')
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/resend-verification', methods=['POST'])
|
@auth_bp.route('/resend-verification', methods=['POST'])
|
||||||
@@ -571,48 +571,48 @@ def verify_email(token):
|
|||||||
def resend_verification():
|
def resend_verification():
|
||||||
"""Resend verification email."""
|
"""Resend verification email."""
|
||||||
if not is_email_verification_enabled():
|
if not is_email_verification_enabled():
|
||||||
flash('Email verification is not enabled.', 'danger')
|
flash("La vérification de courriel n'est pas activée.", 'danger')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
if not is_smtp_configured():
|
if not is_smtp_configured():
|
||||||
flash('Email service is not configured.', 'danger')
|
flash("Le service de courriel n'est pas configuré.", 'danger')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
# Get email from session (set during failed login) or form
|
# Get email from session (set during failed login) or form
|
||||||
email = session.get('unverified_email') or request.form.get('email')
|
email = session.get('unverified_email') or request.form.get('email')
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
flash('Email address is required.', 'danger')
|
flash("L'adresse courriel est requise.", 'danger')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
# Don't reveal if user exists
|
# Don't reveal if user exists (anti-enumeration — Loi 25 / OWASP A01)
|
||||||
flash('If an account exists with this email, a verification link has been sent.', 'info')
|
flash('Si un compte existe pour ce courriel, un lien de vérification a été envoyé.', 'info')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
if user.email_verified:
|
if user.email_verified:
|
||||||
flash('Your email has already been verified.', 'info')
|
flash('Votre courriel a déjà été vérifié.', 'info')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
# Check cooldown
|
# Check cooldown
|
||||||
can_resend, remaining = can_resend_verification(user)
|
can_resend, remaining = can_resend_verification(user)
|
||||||
if not can_resend:
|
if not can_resend:
|
||||||
flash(f'Please wait {remaining} seconds before requesting another verification email.', 'warning')
|
flash(f'Veuillez attendre {remaining} secondes avant de demander un nouveau courriel.', 'warning')
|
||||||
return render_template('auth/check_email.html',
|
return render_template('auth/check_email.html',
|
||||||
title='Check Your Email',
|
title='Vérification requise',
|
||||||
email=email,
|
email=email,
|
||||||
action='verification_required',
|
action='verification_required',
|
||||||
show_resend=True)
|
show_resend=True)
|
||||||
|
|
||||||
if send_verification_email(user):
|
if send_verification_email(user):
|
||||||
flash('A new verification email has been sent.', 'success')
|
flash('Un nouveau courriel de vérification a été envoyé.', 'success')
|
||||||
else:
|
else:
|
||||||
flash('Failed to send verification email. Please try again later.', 'danger')
|
flash("L'envoi du courriel de vérification a échoué. Réessayez plus tard.", 'danger')
|
||||||
|
|
||||||
return render_template('auth/check_email.html',
|
return render_template('auth/check_email.html',
|
||||||
title='Check Your Email',
|
title='Confirmez votre courriel',
|
||||||
email=email,
|
email=email,
|
||||||
action='verification',
|
action='verification',
|
||||||
show_resend=True)
|
show_resend=True)
|
||||||
@@ -628,15 +628,15 @@ def forgot_password():
|
|||||||
return redirect(url_for('recordings.index'))
|
return redirect(url_for('recordings.index'))
|
||||||
|
|
||||||
if not is_smtp_configured():
|
if not is_smtp_configured():
|
||||||
flash('Password reset is not available. Please contact the administrator.', 'warning')
|
flash("La réinitialisation de mot de passe n'est pas disponible. Contactez l'administrateur.", 'warning')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
email = request.form.get('email')
|
email = request.form.get('email')
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
flash('Email address is required.', 'danger')
|
flash("L'adresse courriel est requise.", 'danger')
|
||||||
return render_template('auth/forgot_password.html', title='Forgot Password')
|
return render_template('auth/forgot_password.html', title='Mot de passe oublié')
|
||||||
|
|
||||||
user = User.query.filter_by(email=email).first()
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
@@ -644,20 +644,19 @@ def forgot_password():
|
|||||||
if user:
|
if user:
|
||||||
# Check if user has a password (not SSO-only)
|
# Check if user has a password (not SSO-only)
|
||||||
if user.password:
|
if user.password:
|
||||||
# Check cooldown
|
# Check cooldown — silently skip resend; we still show the
|
||||||
can_resend, remaining = can_resend_password_reset(user)
|
# same generic flash to avoid leaking that an account exists.
|
||||||
if not can_resend:
|
can_resend, _remaining = can_resend_password_reset(user)
|
||||||
flash(f'Please wait {remaining} seconds before requesting another reset email.', 'warning')
|
if can_resend:
|
||||||
else:
|
|
||||||
send_password_reset_email(user)
|
send_password_reset_email(user)
|
||||||
|
|
||||||
flash('If an account exists with this email, a password reset link has been sent.', 'info')
|
flash('Si un compte existe pour ce courriel, un lien de réinitialisation a été envoyé.', 'info')
|
||||||
return render_template('auth/check_email.html',
|
return render_template('auth/check_email.html',
|
||||||
title='Check Your Email',
|
title='Vérifiez votre courriel',
|
||||||
email=email,
|
email=email,
|
||||||
action='password_reset')
|
action='password_reset')
|
||||||
|
|
||||||
return render_template('auth/forgot_password.html', title='Forgot Password')
|
return render_template('auth/forgot_password.html', title='Mot de passe oublié')
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
||||||
@@ -670,12 +669,12 @@ def reset_password(token):
|
|||||||
user_id = verify_reset_token(token)
|
user_id = verify_reset_token(token)
|
||||||
|
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
flash('The password reset link is invalid or has expired.', 'danger')
|
flash('Le lien de réinitialisation est invalide ou expiré.', 'danger')
|
||||||
return redirect(url_for('auth.forgot_password'))
|
return redirect(url_for('auth.forgot_password'))
|
||||||
|
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
flash('User not found.', 'danger')
|
flash('Utilisateur introuvable.', 'danger')
|
||||||
return redirect(url_for('auth.forgot_password'))
|
return redirect(url_for('auth.forgot_password'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
@@ -683,19 +682,19 @@ def reset_password(token):
|
|||||||
confirm_password = request.form.get('confirm_password')
|
confirm_password = request.form.get('confirm_password')
|
||||||
|
|
||||||
if not password or not confirm_password:
|
if not password or not confirm_password:
|
||||||
flash('Both password fields are required.', 'danger')
|
flash('Les deux champs de mot de passe sont requis.', 'danger')
|
||||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)
|
||||||
|
|
||||||
if password != confirm_password:
|
if password != confirm_password:
|
||||||
flash('Passwords do not match.', 'danger')
|
flash('Les mots de passe ne correspondent pas.', 'danger')
|
||||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)
|
||||||
|
|
||||||
# Validate password
|
# Validate password
|
||||||
try:
|
try:
|
||||||
password_check(None, type('obj', (object,), {'data': password}))
|
password_check(None, type('obj', (object,), {'data': password}))
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
flash(str(e), 'danger')
|
flash(str(e), 'danger')
|
||||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)
|
||||||
|
|
||||||
# Update password
|
# Update password
|
||||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
@@ -710,10 +709,10 @@ def reset_password(token):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
audit_password_reset(user.id)
|
audit_password_reset(user.id)
|
||||||
|
|
||||||
flash('Your password has been reset. You can now log in with your new password.', 'success')
|
flash('Votre mot de passe a été réinitialisé. Vous pouvez vous connecter.', 'success')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
return render_template('auth/reset_password.html', title='Reset Password', token=token)
|
return render_template('auth/reset_password.html', title='Nouveau mot de passe', token=token)
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/account', methods=['GET', 'POST'])
|
@auth_bp.route('/account', methods=['GET', 'POST'])
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ PASSWORD_RESET_EXPIRY = 1 * 60 * 60 # 1 hour in seconds
|
|||||||
|
|
||||||
|
|
||||||
def get_email_config():
|
def get_email_config():
|
||||||
"""Get email configuration from environment variables."""
|
"""Get email configuration from environment variables.
|
||||||
|
|
||||||
|
Defaults are tuned for DictIA + Resend SMTP. Operators MUST set
|
||||||
|
``SMTP_FROM_ADDRESS`` to a domain verified in their Resend dashboard
|
||||||
|
(e.g. ``noreply@dictia.ca``).
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true',
|
'enabled': os.environ.get('ENABLE_EMAIL_VERIFICATION', 'false').lower() == 'true',
|
||||||
'required': os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true',
|
'required': os.environ.get('REQUIRE_EMAIL_VERIFICATION', 'false').lower() == 'true',
|
||||||
@@ -35,7 +40,7 @@ def get_email_config():
|
|||||||
'smtp_use_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true',
|
'smtp_use_tls': os.environ.get('SMTP_USE_TLS', 'true').lower() == 'true',
|
||||||
'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true',
|
'smtp_use_ssl': os.environ.get('SMTP_USE_SSL', 'false').lower() == 'true',
|
||||||
'from_address': os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com'),
|
'from_address': os.environ.get('SMTP_FROM_ADDRESS', 'noreply@yourdomain.com'),
|
||||||
'from_name': os.environ.get('SMTP_FROM_NAME', 'Speakr'),
|
'from_name': os.environ.get('SMTP_FROM_NAME', 'DictIA'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -165,32 +170,46 @@ def _send_email(to_email: str, subject: str, html_body: str, text_body: str = No
|
|||||||
|
|
||||||
def _get_email_template(content_html: str, content_text: str, subject: str) -> tuple[str, str]:
|
def _get_email_template(content_html: str, content_text: str, subject: str) -> tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Wrap content in the Speakr email template.
|
Wrap content in the DictIA branded email template.
|
||||||
|
|
||||||
|
Header uses the DictIA brand gradient (118deg, #0062ff → #00bdd8 → #00c896)
|
||||||
|
with a #0062ff fallback for clients that don't render gradients in inline
|
||||||
|
styles. Footer mentions ``info@dictia.ca`` (canonical contact) and the
|
||||||
|
Loi 25 tagline.
|
||||||
|
|
||||||
Returns (html_body, text_body)
|
Returns (html_body, text_body)
|
||||||
"""
|
"""
|
||||||
# Get the base URL for the logo
|
# Get the base URL for the logo. We prefer the dedicated DictIA logo
|
||||||
|
# (logo-dictia.png) over the legacy PWA icon.
|
||||||
try:
|
try:
|
||||||
logo_url = url_for('static', filename='img/icon-192x192.png', _external=True)
|
logo_url = url_for('static', filename='img/logo-dictia.png', _external=True)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# Outside of request context, use a placeholder
|
# Outside of request context, use a placeholder
|
||||||
logo_url = ""
|
logo_url = ""
|
||||||
|
|
||||||
|
# Header: solid #0062ff fallback + linear-gradient overlay (best-effort
|
||||||
|
# for the email clients that support inline-style gradients — Apple Mail,
|
||||||
|
# iOS Mail, Gmail web).
|
||||||
|
header_bg = (
|
||||||
|
"background-color: #0062ff; "
|
||||||
|
"background-image: linear-gradient(118deg, #0062ff 0%, #00bdd8 52%, #00c896 100%);"
|
||||||
|
)
|
||||||
|
|
||||||
html_body = f"""
|
html_body = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="fr-CA">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
</head>
|
</head>
|
||||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #1f2937; margin: 0; padding: 0; background-color: #e8eaed;">
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #060d1a; margin: 0; padding: 0; background-color: #f7f9fc;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #e8eaed;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f7f9fc;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 40px 20px;">
|
<td style="padding: 40px 20px;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; margin: 0 auto;">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #2563eb; padding: 32px 40px; border-radius: 12px 12px 0 0;">
|
<td style="{header_bg} padding: 32px 40px; border-radius: 12px 12px 0 0;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -198,10 +217,10 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
|
|||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align: middle; padding-right: 12px;">
|
<td style="vertical-align: middle; padding-right: 12px;">
|
||||||
<img src="{logo_url}" alt="Speakr" width="44" height="44" style="display: block; border-radius: 8px;">
|
<img src="{logo_url}" alt="DictIA" width="44" height="44" style="display: block; border-radius: 8px;">
|
||||||
</td>
|
</td>
|
||||||
<td style="vertical-align: middle;">
|
<td style="vertical-align: middle;">
|
||||||
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">Speakr</h1>
|
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700; letter-spacing: -0.5px;">DictIA</h1>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -209,7 +228,7 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding-top: 8px;">
|
<td style="padding-top: 8px;">
|
||||||
<p style="color: rgba(255,255,255,0.85); margin: 0; font-size: 14px;">AI-Powered Audio Transcription</p>
|
<p style="color: rgba(255,255,255,0.92); margin: 0; font-size: 14px;">Transcription IA conforme Loi 25</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -218,22 +237,22 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #ffffff; padding: 40px; border-left: 1px solid #e5e7eb; border-right: 1px solid #e5e7eb;">
|
<td style="background-color: #ffffff; padding: 40px; border-left: 1px solid #e6ebf2; border-right: 1px solid #e6ebf2;">
|
||||||
{content_html}
|
{content_html}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="background-color: #f8f9fa; padding: 24px 40px; border-radius: 0 0 12px 12px; border: 1px solid #e5e7eb; border-top: none;">
|
<td style="background-color: #f7f9fc; padding: 24px 40px; border-radius: 0 0 12px 12px; border: 1px solid #e6ebf2; border-top: none;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="text-align: center;">
|
<td style="text-align: center;">
|
||||||
<p style="color: #6b7280; font-size: 12px; margin: 0 0 8px 0;">
|
<p style="color: #4b5563; font-size: 12px; margin: 0 0 8px 0;">
|
||||||
This email was sent by Speakr. If you have questions, please contact your administrator.
|
Ce courriel vous est envoyé par DictIA. Pour toute question, contactez <a href="mailto:info@dictia.ca" style="color: #0062ff; text-decoration: none;">info@dictia.ca</a>.
|
||||||
</p>
|
</p>
|
||||||
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
|
<p style="color: #6b7280; font-size: 11px; margin: 0;">
|
||||||
© {datetime.utcnow().year} Speakr · AI-Powered Audio Transcription
|
© {datetime.utcnow().year} DictIA — Transcription IA conforme Loi 25
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -255,8 +274,8 @@ def _get_email_template(content_html: str, content_text: str, subject: str) -> t
|
|||||||
{content_text}
|
{content_text}
|
||||||
|
|
||||||
---
|
---
|
||||||
This email was sent by Speakr - AI-Powered Audio Transcription.
|
Ce courriel vous est envoyé par DictIA — Transcription IA conforme Loi 25.
|
||||||
If you have questions, please contact your administrator.
|
Pour toute question, contactez info@dictia.ca.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return html_body, text_body
|
return html_body, text_body
|
||||||
@@ -290,41 +309,44 @@ def send_verification_email(user) -> bool:
|
|||||||
# Build verification URL
|
# Build verification URL
|
||||||
verify_url = url_for('auth.verify_email', token=token, _external=True)
|
verify_url = url_for('auth.verify_email', token=token, _external=True)
|
||||||
|
|
||||||
subject = "Verify your email address - Speakr"
|
# Display name preferred over username; fallback when name is None/empty.
|
||||||
|
display_name = (getattr(user, 'name', None) or user.username).strip()
|
||||||
|
|
||||||
|
subject = "Vérifiez votre courriel — DictIA"
|
||||||
|
|
||||||
content_html = f"""
|
content_html = f"""
|
||||||
<h2 style="color: #1f2937; margin: 0 0 24px 0; font-size: 24px; font-weight: 600;">Verify Your Email Address</h2>
|
<h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Vérifiez votre adresse courriel</h2>
|
||||||
|
|
||||||
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Hi {user.username},</p>
|
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name},</p>
|
||||||
|
|
||||||
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
|
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
|
||||||
Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address.
|
Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="text-align: center; margin: 32px 0;">
|
<div style="text-align: center; margin: 32px 0;">
|
||||||
<a href="{verify_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Verify Email Address</a>
|
<a href="{verify_url}" style="display: inline-block; background-color: #0062ff; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Vérifier mon courriel</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</p>
|
<p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
|
||||||
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f3f4f6; border-radius: 6px;">{verify_url}</p>
|
<p style="word-break: break-all; color: #0062ff; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{verify_url}</p>
|
||||||
|
|
||||||
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
|
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
|
||||||
<p style="color: #9ca3af; font-size: 13px; margin: 0;">
|
<p style="color: #4b5563; font-size: 13px; margin: 0;">
|
||||||
<strong>This link will expire in 24 hours.</strong><br>
|
<strong>Ce lien expire dans 24 heures.</strong><br>
|
||||||
If you didn't create an account on Speakr, you can safely ignore this email.
|
Si vous n'avez pas créé de compte DictIA, ignorez ce courriel.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
content_text = f"""Hi {user.username},
|
content_text = f"""Bonjour {display_name},
|
||||||
|
|
||||||
Welcome to Speakr! To complete your registration and start transcribing your audio recordings, please verify your email address.
|
Bienvenue chez DictIA. Pour terminer votre inscription et commencer à transcrire, vérifiez votre adresse courriel.
|
||||||
|
|
||||||
Click here to verify: {verify_url}
|
Cliquez ici pour vérifier : {verify_url}
|
||||||
|
|
||||||
This link will expire in 24 hours.
|
Ce lien expire dans 24 heures.
|
||||||
|
|
||||||
If you didn't create an account on Speakr, you can safely ignore this email."""
|
Si vous n'avez pas créé de compte DictIA, ignorez ce courriel."""
|
||||||
|
|
||||||
html_body, text_body = _get_email_template(content_html, content_text, subject)
|
html_body, text_body = _get_email_template(content_html, content_text, subject)
|
||||||
return _send_email(user.email, subject, html_body, text_body)
|
return _send_email(user.email, subject, html_body, text_body)
|
||||||
@@ -354,50 +376,44 @@ def send_password_reset_email(user) -> bool:
|
|||||||
# Build reset URL
|
# Build reset URL
|
||||||
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
reset_url = url_for('auth.reset_password', token=token, _external=True)
|
||||||
|
|
||||||
subject = "Reset your password - Speakr"
|
# Display name preferred over username; fallback when name is None/empty.
|
||||||
|
display_name = (getattr(user, 'name', None) or user.username).strip()
|
||||||
|
|
||||||
|
subject = "Réinitialiser votre mot de passe — DictIA"
|
||||||
|
|
||||||
content_html = f"""
|
content_html = f"""
|
||||||
<h2 style="color: #1f2937; margin: 0 0 24px 0; font-size: 24px; font-weight: 600;">Reset Your Password</h2>
|
<h2 style="color: #060d1a; margin: 0 0 24px 0; font-size: 24px; font-weight: 700;">Réinitialiser votre mot de passe</h2>
|
||||||
|
|
||||||
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Hi {user.username},</p>
|
<p style="color: #374151; margin: 0 0 16px 0; font-size: 16px;">Bonjour {display_name},</p>
|
||||||
|
|
||||||
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
|
<p style="color: #374151; margin: 0 0 24px 0; font-size: 16px;">
|
||||||
We received a request to reset your Speakr account password. Click the button below to create a new password.
|
Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="text-align: center; margin: 32px 0;">
|
<div style="text-align: center; margin: 32px 0;">
|
||||||
<a href="{reset_url}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Reset Password</a>
|
<a href="{reset_url}" style="display: inline-block; background-color: #0062ff; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">Réinitialiser mon mot de passe</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="color: #6b7280; font-size: 14px; margin: 24px 0 8px 0;">Or copy and paste this link into your browser:</p>
|
<p style="color: #4b5563; font-size: 14px; margin: 24px 0 8px 0;">Ou copiez-collez ce lien dans votre navigateur :</p>
|
||||||
<p style="word-break: break-all; color: #2563eb; font-size: 14px; margin: 0; padding: 12px; background-color: #f3f4f6; border-radius: 6px;">{reset_url}</p>
|
<p style="word-break: break-all; color: #0062ff; font-size: 14px; margin: 0; padding: 12px; background-color: #f7f9fc; border-radius: 6px;">{reset_url}</p>
|
||||||
|
|
||||||
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
|
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e6ebf2;">
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
<p style="color: #4b5563; font-size: 13px; margin: 0;">
|
||||||
<tr>
|
<strong>Ce lien expire dans 1 heure.</strong><br>
|
||||||
<td style="width: 24px; vertical-align: top; padding-right: 12px;">
|
Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé.
|
||||||
<span style="font-size: 18px;">⚠️</span>
|
</p>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<p style="color: #9ca3af; font-size: 13px; margin: 0;">
|
|
||||||
<strong style="color: #6b7280;">This link will expire in 1 hour.</strong><br>
|
|
||||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
content_text = f"""Hi {user.username},
|
content_text = f"""Bonjour {display_name},
|
||||||
|
|
||||||
We received a request to reset your Speakr account password. Click the link below to create a new password:
|
Nous avons reçu une demande de réinitialisation pour votre compte DictIA. Cliquez sur le lien ci-dessous pour créer un nouveau mot de passe :
|
||||||
|
|
||||||
{reset_url}
|
{reset_url}
|
||||||
|
|
||||||
This link will expire in 1 hour.
|
Ce lien expire dans 1 heure.
|
||||||
|
|
||||||
If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged."""
|
Si vous n'avez pas demandé de réinitialisation, ignorez ce courriel — votre mot de passe reste inchangé."""
|
||||||
|
|
||||||
html_body, text_body = _get_email_template(content_html, content_text, subject)
|
html_body, text_body = _get_email_template(content_html, content_text, subject)
|
||||||
return _send_email(user.email, subject, html_body, text_body)
|
return _send_email(user.email, subject, html_body, text_body)
|
||||||
|
|||||||
@@ -747,9 +747,6 @@
|
|||||||
.h-16 {
|
.h-16 {
|
||||||
height: calc(var(--spacing) * 16);
|
height: calc(var(--spacing) * 16);
|
||||||
}
|
}
|
||||||
.h-20 {
|
|
||||||
height: calc(var(--spacing) * 20);
|
|
||||||
}
|
|
||||||
.h-24 {
|
.h-24 {
|
||||||
height: calc(var(--spacing) * 24);
|
height: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
@@ -1668,9 +1665,6 @@
|
|||||||
.bg-\[var\(--bg-warn-light\)\] {
|
.bg-\[var\(--bg-warn-light\)\] {
|
||||||
background-color: var(--bg-warn-light);
|
background-color: var(--bg-warn-light);
|
||||||
}
|
}
|
||||||
.bg-\[var\(--bg-warning-light\)\] {
|
|
||||||
background-color: var(--bg-warning-light);
|
|
||||||
}
|
|
||||||
.bg-\[var\(--border-accent\)\] {
|
.bg-\[var\(--border-accent\)\] {
|
||||||
background-color: var(--border-accent);
|
background-color: var(--border-accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,127 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'marketing/base.html' %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
|
||||||
<title>{{ title }} - DictIA</title>
|
|
||||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
|
||||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
|
|
||||||
{% include 'includes/loading_overlay.html' %}
|
{% block title %}{% if action == 'password_reset' %}Vérifiez votre courriel — DictIA{% else %}Confirmez votre courriel — DictIA{% endif %}{% endblock %}
|
||||||
|
{% block description %}Un courriel vous a été envoyé. Suivez le lien pour activer votre compte DictIA.{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block content %}
|
||||||
function applyTheme() {
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="check-email-title">
|
||||||
if (!document.documentElement) return;
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta text-center">
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
<div class="mx-auto mb-6 w-16 h-16 rounded-full grad-bg flex items-center justify-center text-white text-2xl" aria-hidden="true">✉</div>
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
|
||||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
|
||||||
themeClasses.forEach(theme => {
|
|
||||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
|
||||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
|
||||||
});
|
|
||||||
if (savedScheme !== 'blue') {
|
|
||||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyTheme();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
|
||||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
|
||||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
|
||||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
|
||||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
|
||||||
DictIA
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex items-center justify-center">
|
<h1 id="check-email-title" class="text-2xl font-black text-brand-navy mb-2">
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
{% if action == 'password_reset' %}Vérifiez votre courriel
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% elif action == 'verification_required' %}Vérification requise
|
||||||
{% if messages %}
|
{% else %}Confirmez votre courriel{% endif %}
|
||||||
{% for category, message in messages %}
|
</h1>
|
||||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="text-center">
|
<p class="text-sm text-brand-navy/70 mb-6">
|
||||||
<div class="mb-6">
|
{% if action == 'password_reset' %}
|
||||||
<div class="w-20 h-20 mx-auto bg-[var(--bg-info-light)] rounded-full flex items-center justify-center">
|
{{ "Si un compte DictIA existe pour <strong>" ~ email ~ "</strong>, vous recevrez un courriel avec un lien pour réinitialiser votre mot de passe. Le lien expire dans 1 heure." | safe }}
|
||||||
<i class="fas fa-envelope text-[var(--text-info-strong)] text-3xl"></i>
|
{% elif action == 'verification_required' %}
|
||||||
</div>
|
{{ "Vérifiez votre boîte de réception à <strong>" ~ email ~ "</strong>. Si vous ne recevez rien, demandez un nouveau courriel ci-dessous." | safe }}
|
||||||
</div>
|
{% else %}
|
||||||
|
{{ "Nous avons envoyé un lien de vérification à <strong>" ~ email ~ "</strong>. Cliquez dessus pour activer votre compte. Le lien expire dans 24 heures." | safe }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if action == 'verification' %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
|
{% if messages %}
|
||||||
<p class="text-[var(--text-secondary)] mb-2">We've sent a verification link to:</p>
|
{% for category, message in messages %}
|
||||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||||
Click the link in the email to verify your account. The link will expire in 24 hours.
|
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||||
</p>
|
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||||
{% elif action == 'verification_required' %}
|
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verification Required</h2>
|
{{ message }}
|
||||||
<p class="text-[var(--text-secondary)] mb-2">Please verify your email address:</p>
|
</div>
|
||||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
{% endfor %}
|
||||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
{% endif %}
|
||||||
Check your inbox for a verification email. If you haven't received it, you can request a new one.
|
{% endwith %}
|
||||||
</p>
|
|
||||||
{% elif action == 'password_reset' %}
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Check Your Email</h2>
|
|
||||||
<p class="text-[var(--text-secondary)] mb-2">If an account exists with this email:</p>
|
|
||||||
<p class="text-[var(--text-primary)] font-medium mb-6">{{ email }}</p>
|
|
||||||
<p class="text-[var(--text-muted)] text-sm mb-6">
|
|
||||||
We've sent a password reset link. The link will expire in 1 hour.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if show_resend and (action == 'verification' or action == 'verification_required') %}
|
{% if show_resend and action != 'password_reset' %}
|
||||||
<div class="mb-6">
|
<form method="POST" action="{{ url_for('auth.resend_verification') }}" class="mb-4">
|
||||||
<form method="POST" action="{{ url_for('auth.resend_verification') }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="email" value="{{ email }}">
|
||||||
<input type="hidden" name="email" value="{{ email }}">
|
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
<button type="submit" class="text-[var(--text-accent)] hover:underline text-sm">
|
Renvoyer le lien de vérification
|
||||||
<i class="fas fa-redo mr-1"></i> Resend verification email
|
</button>
|
||||||
</button>
|
</form>
|
||||||
</form>
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="pt-4 border-t border-[var(--border-secondary)]">
|
<p class="text-xs text-brand-navy/70 mt-4">
|
||||||
<a href="{{ url_for('auth.login') }}" class="text-[var(--text-accent)] hover:underline">
|
Vous ne recevez rien ? Vérifiez vos pourriels (spam) ou
|
||||||
<i class="fas fa-arrow-left mr-1"></i> Back to Login
|
<a href="mailto:info@dictia.ca" class="grad-text font-semibold">contactez le support</a>.
|
||||||
</a>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
<p class="mt-6 text-sm">
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">← Retour à la connexion</a>
|
||||||
</footer>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<script>
|
{% endblock %}
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,105 +1,46 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'marketing/base.html' %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
|
||||||
<title>{{ title }} - DictIA</title>
|
|
||||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
|
||||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
|
|
||||||
{% include 'includes/loading_overlay.html' %}
|
{% block title %}Mot de passe oublié — DictIA{% endblock %}
|
||||||
|
{% block description %}Recevez un lien sécurisé pour réinitialiser le mot de passe de votre compte DictIA.{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block content %}
|
||||||
function applyTheme() {
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="forgot-title">
|
||||||
if (!document.documentElement) return;
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
<h1 id="forgot-title" class="text-3xl font-black text-brand-navy mb-2">Mot de passe oublié</h1>
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
<p class="text-sm text-brand-navy/70 mb-6">{{ "Entrez votre adresse courriel. Si un compte existe, nous vous enverrons un lien sécurisé pour réinitialiser votre mot de passe (valide 1 heure)." | safe }}</p>
|
||||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
|
||||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
|
||||||
themeClasses.forEach(theme => {
|
|
||||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
|
||||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
|
||||||
});
|
|
||||||
if (savedScheme !== 'blue') {
|
|
||||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyTheme();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
|
||||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
|
||||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
|
||||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
|
||||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
|
||||||
DictIA
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex items-center justify-center">
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
{% if messages %}
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Forgot Password</h2>
|
{% for category, message in messages %}
|
||||||
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
|
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||||
Enter your email address and we'll send you a link to reset your password.
|
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||||
</p>
|
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||||
|
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||||
|
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||||
|
{{ message | safe }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
<form method="POST" action="{{ url_for('auth.forgot_password') }}" class="space-y-4" novalidate>
|
||||||
{% if messages %}
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('auth.forgot_password') }}">
|
<div>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<label for="email" class="block text-sm font-medium text-brand-navy mb-1">Courriel <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="email" required aria-required="true"
|
||||||
|
class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
|
||||||
|
placeholder="vous@cabinet.qc.ca">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-6">
|
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
<label for="email" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Email Address</label>
|
Recevoir un lien de réinitialisation
|
||||||
<input type="email" id="email" name="email" required
|
</button>
|
||||||
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]"
|
</form>
|
||||||
placeholder="Enter your email address">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
|
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">← Retour à la connexion</a>
|
||||||
<i class="fas fa-paper-plane mr-2"></i> Send Reset Link
|
</p>
|
||||||
</button>
|
</div>
|
||||||
|
</section>
|
||||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
{% endblock %}
|
||||||
<span>Remember your password?</span>
|
|
||||||
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">Back to Login</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,114 +1,54 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'marketing/base.html' %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
|
||||||
<title>{{ title }} - DictIA</title>
|
|
||||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
|
||||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
|
|
||||||
{% include 'includes/loading_overlay.html' %}
|
{% block title %}Nouveau mot de passe — DictIA{% endblock %}
|
||||||
|
{% block description %}Définissez un nouveau mot de passe pour votre compte DictIA. Lien sécurisé valide 1 heure.{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block content %}
|
||||||
function applyTheme() {
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="reset-title">
|
||||||
if (!document.documentElement) return;
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta">
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
<h1 id="reset-title" class="text-3xl font-black text-brand-navy mb-2">Nouveau mot de passe</h1>
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
<p class="text-sm text-brand-navy/70 mb-6">Choisissez un mot de passe robuste pour sécuriser votre compte DictIA.</p>
|
||||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
|
||||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
|
||||||
themeClasses.forEach(theme => {
|
|
||||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
|
||||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
|
||||||
});
|
|
||||||
if (savedScheme !== 'blue') {
|
|
||||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyTheme();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
|
||||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
|
||||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
|
||||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
|
||||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
|
||||||
DictIA
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex items-center justify-center">
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
{% if messages %}
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-2 text-center">Reset Password</h2>
|
{% for category, message in messages %}
|
||||||
<p class="text-[var(--text-muted)] text-sm text-center mb-6">
|
<div role="alert" class="mb-3 p-3 rounded-lg text-sm
|
||||||
Enter your new password below.
|
{% if category == 'danger' %}bg-red-50 text-red-900 border border-red-200
|
||||||
</p>
|
{% elif category == 'warning' %}bg-amber-50 text-amber-900 border border-amber-200
|
||||||
|
{% elif category == 'success' %}bg-green-50 text-green-900 border border-green-200
|
||||||
|
{% else %}bg-blue-50 text-blue-900 border border-blue-200{% endif %}">
|
||||||
|
{{ message | safe }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}" class="space-y-4" novalidate>
|
||||||
{% if messages %}
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="mb-4 p-3 rounded-lg {% if category == 'success' %}bg-[var(--bg-success-light)] text-[var(--text-success-strong)]{% elif category == 'danger' %}bg-[var(--bg-danger-light)] text-[var(--text-danger-strong)]{% elif category == 'warning' %}bg-[var(--bg-warning-light)] text-[var(--text-warning-strong)]{% else %}bg-[var(--bg-info-light)] text-[var(--text-info-strong)]{% endif %}">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<form method="POST" action="{{ url_for('auth.reset_password', token=token) }}">
|
<div>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<label for="password" class="block text-sm font-medium text-brand-navy mb-1">Nouveau mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="new-password" minlength="8" required aria-required="true" aria-describedby="password-help"
|
||||||
|
class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
|
||||||
|
placeholder="••••••••">
|
||||||
|
<p id="password-help" class="text-xs text-brand-navy/70 mt-1">{{ "8 caractères minimum, dont une majuscule, une minuscule, un chiffre et un caractère spécial." | safe }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<label for="password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">New Password</label>
|
<label for="confirm_password" class="block text-sm font-medium text-brand-navy mb-1">Confirmer le mot de passe <span class="text-red-600" aria-hidden="true">*</span></label>
|
||||||
<input type="password" id="password" name="password" required
|
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" minlength="8" required aria-required="true"
|
||||||
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]"
|
class="w-full px-3 py-2 border border-brand-border rounded-[0.5rem] text-brand-navy focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2"
|
||||||
placeholder="Enter your new password">
|
placeholder="••••••••">
|
||||||
<p class="text-xs text-[var(--text-muted)] mt-1">Password must be at least 8 characters long.</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-6">
|
<button type="submit" class="w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
<label for="confirm_password" class="block text-sm font-medium text-[var(--text-secondary)] mb-1">Confirm Password</label>
|
Définir mon nouveau mot de passe
|
||||||
<input type="password" id="confirm_password" name="confirm_password" required
|
</button>
|
||||||
class="mt-1 block w-full rounded-md border-[var(--border-secondary)] shadow-sm focus:border-[var(--border-focus)] focus:ring-[var(--ring-focus)] focus:ring-opacity-50 bg-[var(--bg-input)] text-[var(--text-primary)]"
|
</form>
|
||||||
placeholder="Confirm your new password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col space-y-4">
|
<p class="text-center text-sm text-brand-navy/70 mt-6">
|
||||||
<button type="submit" class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)]">
|
<a href="{{ url_for('auth.login') }}" class="grad-text font-semibold">← Retour à la connexion</a>
|
||||||
<i class="fas fa-key mr-2"></i> Reset Password
|
</p>
|
||||||
</button>
|
</div>
|
||||||
|
</section>
|
||||||
<div class="text-center text-sm text-[var(--text-muted)]">
|
{% endblock %}
|
||||||
<a href="{{ url_for('auth.login') }}" class="font-medium text-[var(--text-accent)] hover:underline">
|
|
||||||
<i class="fas fa-arrow-left mr-1"></i> Back to Login
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,85 +1,27 @@
|
|||||||
<!DOCTYPE html>
|
{% extends 'marketing/base.html' %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex">
|
|
||||||
<title>{{ title }} - DictIA</title>
|
|
||||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/svg+xml">
|
|
||||||
<script src="{{ url_for('static', filename='vendor/js/tailwind.min.js') }}"></script>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/css/fontawesome.min.css') }}">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
||||||
|
|
||||||
{% include 'includes/loading_overlay.html' %}
|
{% block title %}Courriel vérifié — DictIA{% endblock %}
|
||||||
|
{% block description %}Votre courriel a été vérifié. Vous pouvez maintenant vous connecter à votre compte DictIA.{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block content %}
|
||||||
function applyTheme() {
|
<section class="min-h-[calc(100vh-62px)] bg-brand-bg py-16 px-4" aria-labelledby="verify-success-title">
|
||||||
if (!document.documentElement) return;
|
<div class="max-w-md mx-auto bg-white p-8 rounded-[18px] border border-brand-border shadow-cta text-center">
|
||||||
const savedMode = localStorage.getItem('darkMode');
|
<div class="mx-auto mb-6 w-16 h-16 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-3xl font-black" aria-hidden="true">✓</div>
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
if (savedMode === 'true' || (savedMode === null && prefersDark)) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
const savedScheme = localStorage.getItem('colorScheme') || 'blue';
|
|
||||||
const isDark = document.documentElement.classList.contains('dark');
|
|
||||||
const themePrefix = isDark ? 'theme-dark-' : 'theme-light-';
|
|
||||||
const themeClasses = ['blue', 'emerald', 'purple', 'rose', 'amber', 'teal'];
|
|
||||||
themeClasses.forEach(theme => {
|
|
||||||
document.documentElement.classList.remove(`theme-light-${theme}`);
|
|
||||||
document.documentElement.classList.remove(`theme-dark-${theme}`);
|
|
||||||
});
|
|
||||||
if (savedScheme !== 'blue') {
|
|
||||||
document.documentElement.classList.add(themePrefix + savedScheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyTheme();
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body class="bg-[var(--bg-primary)] text-[var(--text-primary)]">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col min-h-screen">
|
|
||||||
<header class="flex justify-between items-center mb-6 pb-4 border-b border-[var(--border-primary)]">
|
|
||||||
<h1 class="text-3xl font-bold text-[var(--text-primary)]">
|
|
||||||
<a href="{{ url_for('recordings.index') }}" class="flex items-center">
|
|
||||||
<img src="{{ url_for('static', filename='img/logo-dictia.png') }}" alt="DictIA" class="h-14 w-14 mr-3">
|
|
||||||
DictIA
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex items-center justify-center">
|
<h1 id="verify-success-title" class="text-2xl font-black text-brand-navy mb-2">Votre courriel a été vérifié</h1>
|
||||||
<div class="w-full max-w-md bg-[var(--bg-secondary)] p-8 rounded-xl shadow-lg border border-[var(--border-primary)]">
|
<p class="text-sm text-brand-navy/70 mb-6">
|
||||||
<div class="text-center">
|
Vous pouvez maintenant vous connecter à votre compte DictIA et commencer à transcrire en toute conformité Loi 25.
|
||||||
<div class="mb-6">
|
</p>
|
||||||
<div class="w-20 h-20 mx-auto bg-[var(--bg-success-light)] rounded-full flex items-center justify-center">
|
|
||||||
<i class="fas fa-check text-[var(--text-success-strong)] text-3xl"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="text-2xl font-semibold text-[var(--text-primary)] mb-4">Email Verified!</h2>
|
<a href="{{ url_for('auth.login') }}"
|
||||||
<p class="text-[var(--text-secondary)] mb-6">
|
class="inline-block w-full grad-bg text-white font-semibold py-3 rounded-[0.75rem] shadow-cta hover:shadow-cta-hover transition focus-visible:outline-2 focus-visible:outline-brand-b1 focus-visible:outline-offset-2">
|
||||||
Your email address has been successfully verified. You can now log in to your account.
|
Se connecter
|
||||||
</p>
|
</a>
|
||||||
|
|
||||||
<a href="{{ url_for('auth.login') }}" class="inline-block w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-[var(--text-button)] bg-[var(--bg-button)] hover:bg-[var(--bg-button-hover)] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[var(--border-focus)] transition-colors duration-200">
|
<p class="text-xs text-brand-navy/70 mt-6">
|
||||||
<i class="fas fa-sign-in-alt mr-2"></i> Continue to Login
|
Une question ? Écrivez-nous à
|
||||||
</a>
|
<a href="mailto:info@dictia.ca" class="grad-text font-semibold">info@dictia.ca</a>.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</section>
|
||||||
|
{% endblock %}
|
||||||
<footer class="text-center py-4 mt-8 text-xs text-[var(--text-light)] border-t border-[var(--border-primary)]">
|
|
||||||
<div>© {{ now.year }} InnovA AI · <a href="/politique-confidentialite" class="underline hover:text-[var(--text-primary)]">Politique de confidentialité</a> · <a href="/conditions-utilisation" class="underline hover:text-[var(--text-primary)]">Conditions d'utilisation</a></div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
if (window.AppLoader) {
|
|
||||||
AppLoader.waitForReady();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
78
tests/_run_email_service_dictia_windows.py
Normal file
78
tests/_run_email_service_dictia_windows.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Windows manual driver for tests/test_email_service_dictia.py.
|
||||||
|
|
||||||
|
src/init_db.py imports `fcntl`, which is POSIX-only. On Windows we stub it
|
||||||
|
before src.app gets imported, then run each test_* function and report.
|
||||||
|
|
||||||
|
Run from the repo root:
|
||||||
|
py -3 tests/_run_email_service_dictia_windows.py
|
||||||
|
|
||||||
|
This script is local-dev only (not picked up by pytest collection).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
# 1) Stub fcntl BEFORE any import of src.* happens.
|
||||||
|
if 'fcntl' not in sys.modules:
|
||||||
|
fcntl_stub = types.ModuleType('fcntl')
|
||||||
|
fcntl_stub.LOCK_EX = 2
|
||||||
|
fcntl_stub.LOCK_NB = 4
|
||||||
|
fcntl_stub.LOCK_UN = 8
|
||||||
|
fcntl_stub.LOCK_SH = 1
|
||||||
|
fcntl_stub.flock = lambda *_args, **_kw: None
|
||||||
|
fcntl_stub.fcntl = lambda *_args, **_kw: 0
|
||||||
|
sys.modules['fcntl'] = fcntl_stub
|
||||||
|
|
||||||
|
# 2) Make repo root importable
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
REPO = os.path.dirname(HERE)
|
||||||
|
sys.path.insert(0, REPO)
|
||||||
|
|
||||||
|
# 3) Set test config
|
||||||
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||||
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||||
|
os.environ.setdefault('ENABLE_EMAIL_VERIFICATION', 'false')
|
||||||
|
# Avoid sys.exit(1) in src/config/app_config.py legacy validation.
|
||||||
|
os.environ.setdefault('TRANSCRIPTION_BASE_URL', 'http://test-stub')
|
||||||
|
os.environ.setdefault('TRANSCRIPTION_API_KEY', 'test-stub')
|
||||||
|
# Disable rate limits for forgot_password endpoint test.
|
||||||
|
os.environ.setdefault('RATELIMIT_ENABLED', 'false')
|
||||||
|
# Force UTF-8 stdout so src.app's emoji prints don't crash on cp1252 Windows.
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||||
|
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4) Import the test module and run every test_* function it defines
|
||||||
|
import importlib.util # noqa: E402
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
'test_email_service_dictia',
|
||||||
|
os.path.join(HERE, 'test_email_service_dictia.py'),
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
|
||||||
|
tests = [(name, fn) for name, fn in vars(mod).items()
|
||||||
|
if name.startswith('test_') and callable(fn)]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = []
|
||||||
|
for name, fn in tests:
|
||||||
|
try:
|
||||||
|
fn()
|
||||||
|
print(f' PASS {name}')
|
||||||
|
passed += 1
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
print(f' FAIL {name}: {type(e).__name__}: {e}')
|
||||||
|
failed.append((name, traceback.format_exc()))
|
||||||
|
|
||||||
|
total = len(tests)
|
||||||
|
print()
|
||||||
|
print(f'Result: {passed}/{total} passed, {len(failed)} failed')
|
||||||
|
if failed:
|
||||||
|
print('\n--- Failures ---\n')
|
||||||
|
for name, tb in failed:
|
||||||
|
print(f'### {name}\n{tb}\n')
|
||||||
|
sys.exit(0 if not failed else 1)
|
||||||
292
tests/test_email_service_dictia.py
Normal file
292
tests/test_email_service_dictia.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Tests for B-2.3 — DictIA-branded French transactional emails (verification + reset).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- _get_email_template uses DictIA branding (no "Speakr" leaks).
|
||||||
|
- send_verification_email subject/body in French + DictIA.
|
||||||
|
- send_password_reset_email subject/body in French + DictIA.
|
||||||
|
- User display name (user.name) used in greetings, fallback to username.
|
||||||
|
- Anti-enumeration: /forgot-password gives the same flash for known/unknown emails.
|
||||||
|
- Cooldowns are enforced (60s) for resend-verification.
|
||||||
|
- SMTP_FROM_NAME defaults to "DictIA" when env var unset.
|
||||||
|
- send_verification_email returns False (no exception) when SMTP misconfigured.
|
||||||
|
- check_email.html refondu — extends marketing/base.html (DictIA brand tokens, no
|
||||||
|
legacy `var(--text-primary)` styles).
|
||||||
|
|
||||||
|
Note: pytest cannot collect this file on Windows native because src/init_db.py
|
||||||
|
imports `fcntl` (POSIX-only). Tests run in CI / Docker. A manual driver may be
|
||||||
|
provided alongside this file for Windows verification.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
os.environ.setdefault('SQLALCHEMY_DATABASE_URI', 'sqlite:///:memory:')
|
||||||
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||||
|
|
||||||
|
from src.app import app, db # noqa: E402
|
||||||
|
from src.models.user import User # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def _set_smtp_env():
|
||||||
|
os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true'
|
||||||
|
os.environ['SMTP_HOST'] = 'smtp.test'
|
||||||
|
os.environ['SMTP_USERNAME'] = 'u'
|
||||||
|
os.environ['SMTP_PASSWORD'] = 'p'
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_smtp_env():
|
||||||
|
for k in ('ENABLE_EMAIL_VERIFICATION', 'REQUIRE_EMAIL_VERIFICATION',
|
||||||
|
'SMTP_HOST', 'SMTP_USERNAME', 'SMTP_PASSWORD',
|
||||||
|
'SMTP_FROM_NAME', 'SMTP_FROM_ADDRESS'):
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(username='jane', email='jane@x.qc.ca', name='Jane Bouchard'):
|
||||||
|
user = User(username=username, email=email, password='x' * 60,
|
||||||
|
name=name, email_verified=False)
|
||||||
|
db.session.add(user)
|
||||||
|
db.session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
# --- Tests ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_email_template_uses_dictia_branding():
|
||||||
|
"""_get_email_template wraps content in DictIA-branded HTML scaffold (no Speakr)."""
|
||||||
|
with app.app_context():
|
||||||
|
from src.services.email import _get_email_template
|
||||||
|
html, text = _get_email_template(
|
||||||
|
content_html='<p>hello</p>',
|
||||||
|
content_text='hello',
|
||||||
|
subject='Test',
|
||||||
|
)
|
||||||
|
assert 'DictIA' in html, 'HTML must contain DictIA brand'
|
||||||
|
assert 'DictIA' in text, 'Plain text must contain DictIA brand'
|
||||||
|
assert 'Speakr' not in html, 'No "Speakr" string must remain in template'
|
||||||
|
assert 'Speakr' not in text, 'No "Speakr" string must remain in plain text'
|
||||||
|
# French footer copy + canonical contact email
|
||||||
|
assert 'info@dictia.ca' in html
|
||||||
|
assert 'Loi' in html and '25' in html, 'Tagline must mention Loi 25'
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_template_header_uses_brand_gradient_not_speakr_blue():
|
||||||
|
"""Header bg must use DictIA brand color #0062ff (or gradient), not the legacy
|
||||||
|
Speakr #2563eb."""
|
||||||
|
with app.app_context():
|
||||||
|
from src.services.email import _get_email_template
|
||||||
|
html, _ = _get_email_template('x', 'x', 'Test')
|
||||||
|
assert '#2563eb' not in html, 'Legacy Speakr header color must be removed'
|
||||||
|
assert '#0062ff' in html, 'DictIA brand blue must be present'
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_email_subject_is_french_with_dictia():
|
||||||
|
"""Subject = 'Vérifiez votre courriel — DictIA'."""
|
||||||
|
with app.test_request_context('/'):
|
||||||
|
_set_smtp_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user()
|
||||||
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||||
|
from src.services.email import send_verification_email
|
||||||
|
send_verification_email(user)
|
||||||
|
args, _ = mock_send.call_args
|
||||||
|
_to, subject, _html, _text = args
|
||||||
|
assert subject == 'Vérifiez votre courriel — DictIA'
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_email_body_uses_user_name_when_set():
|
||||||
|
"""Greeting uses user.name (display name) when populated."""
|
||||||
|
with app.test_request_context('/'):
|
||||||
|
_set_smtp_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user(username='jane123', email='jane@x.qc.ca',
|
||||||
|
name='Jane Bouchard')
|
||||||
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||||
|
from src.services.email import send_verification_email
|
||||||
|
send_verification_email(user)
|
||||||
|
args, _ = mock_send.call_args
|
||||||
|
_to, _subject, html, text = args
|
||||||
|
assert 'Bonjour Jane Bouchard' in html
|
||||||
|
assert 'Bonjour Jane Bouchard' in text
|
||||||
|
assert 'Bonjour jane123' not in html
|
||||||
|
# French body copy
|
||||||
|
assert 'Vérifier mon courriel' in html
|
||||||
|
assert 'Bienvenue chez DictIA' in html or "Bienvenue chez DictIA" in text
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_email_body_falls_back_to_username():
|
||||||
|
"""When user.name is None, greeting uses user.username."""
|
||||||
|
with app.test_request_context('/'):
|
||||||
|
_set_smtp_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user(username='bob42', email='bob@x.qc.ca', name=None)
|
||||||
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||||
|
from src.services.email import send_verification_email
|
||||||
|
send_verification_email(user)
|
||||||
|
args, _ = mock_send.call_args
|
||||||
|
_to, _subject, html, _text = args
|
||||||
|
assert 'Bonjour bob42' in html
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_reset_subject_french():
|
||||||
|
"""Subject = 'Réinitialiser votre mot de passe — DictIA'."""
|
||||||
|
with app.test_request_context('/'):
|
||||||
|
_set_smtp_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user(username='carol', email='carol@x.qc.ca',
|
||||||
|
name='Carol Tremblay')
|
||||||
|
with patch('src.services.email._send_email', return_value=True) as mock_send:
|
||||||
|
from src.services.email import send_password_reset_email
|
||||||
|
send_password_reset_email(user)
|
||||||
|
args, _ = mock_send.call_args
|
||||||
|
_to, subject, html, _text = args
|
||||||
|
assert subject == 'Réinitialiser votre mot de passe — DictIA'
|
||||||
|
assert 'Bonjour Carol Tremblay' in html
|
||||||
|
assert 'Réinitialiser mon mot de passe' in html
|
||||||
|
assert 'Speakr' not in html
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_verification_returns_false_when_smtp_not_configured():
|
||||||
|
"""No exception, just False — keeps registration robust."""
|
||||||
|
with app.app_context():
|
||||||
|
_clear_smtp_env()
|
||||||
|
# Verification enabled but SMTP missing
|
||||||
|
os.environ['ENABLE_EMAIL_VERIFICATION'] = 'true'
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user()
|
||||||
|
from src.services.email import send_verification_email
|
||||||
|
assert send_verification_email(user) is False
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_from_name_defaults_to_dictia():
|
||||||
|
"""When SMTP_FROM_NAME is unset, get_email_config() returns 'DictIA'."""
|
||||||
|
_clear_smtp_env()
|
||||||
|
from src.services.email import get_email_config
|
||||||
|
cfg = get_email_config()
|
||||||
|
assert cfg['from_name'] == 'DictIA', (
|
||||||
|
'Default SMTP_FROM_NAME must be "DictIA", not "Speakr"'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forgot_password_returns_generic_message_for_unknown_email():
|
||||||
|
"""Anti-enumeration: unknown email gets the same generic message."""
|
||||||
|
with app.app_context():
|
||||||
|
_set_smtp_env()
|
||||||
|
app.config['WTF_CSRF_ENABLED'] = False
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
client = app.test_client()
|
||||||
|
# No user exists with this email
|
||||||
|
with patch('src.services.email._send_email', return_value=True):
|
||||||
|
resp = client.post('/forgot-password',
|
||||||
|
data={'email': 'nobody@nope.qc.ca'})
|
||||||
|
# Page should render the generic message in body
|
||||||
|
body = resp.data.decode('utf-8')
|
||||||
|
assert 'Si un compte' in body or 'lien de réinitialisation' in body
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_forgot_password_returns_same_message_for_known_email():
|
||||||
|
"""Anti-enumeration: known email gets the SAME generic message."""
|
||||||
|
with app.app_context():
|
||||||
|
_set_smtp_env()
|
||||||
|
app.config['WTF_CSRF_ENABLED'] = False
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user(username='dora', email='dora@x.qc.ca')
|
||||||
|
client = app.test_client()
|
||||||
|
with patch('src.services.email._send_email', return_value=True):
|
||||||
|
resp = client.post('/forgot-password',
|
||||||
|
data={'email': user.email})
|
||||||
|
body = resp.data.decode('utf-8')
|
||||||
|
assert 'Si un compte' in body or 'lien de réinitialisation' in body
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_email_template_extends_marketing_base():
|
||||||
|
"""check_email.html uses DictIA marketing layout, no legacy Vue styles."""
|
||||||
|
with app.test_request_context('/'):
|
||||||
|
_set_smtp_env()
|
||||||
|
app.config['WTF_CSRF_ENABLED'] = False
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
from flask import render_template
|
||||||
|
html = render_template(
|
||||||
|
'auth/check_email.html',
|
||||||
|
title='Vérifiez votre courriel',
|
||||||
|
email='alice@x.qc.ca',
|
||||||
|
action='verification',
|
||||||
|
show_resend=True,
|
||||||
|
)
|
||||||
|
# New marketing layout markers
|
||||||
|
assert 'marketing.css' in html or 'grad-text' in html or 'brand-navy' in html
|
||||||
|
# Legacy Vue/Tailwind v3 design tokens MUST be gone
|
||||||
|
assert 'var(--text-primary)' not in html
|
||||||
|
assert 'var(--bg-secondary)' not in html
|
||||||
|
# French + brand
|
||||||
|
assert 'DictIA' in html
|
||||||
|
assert 'alice@x.qc.ca' in html
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resend_verification_rate_limited_per_user():
|
||||||
|
"""can_resend_verification returns (False, remaining) within the 60s cooldown."""
|
||||||
|
with app.app_context():
|
||||||
|
_set_smtp_env()
|
||||||
|
db.create_all()
|
||||||
|
try:
|
||||||
|
user = _make_user(username='eric', email='eric@x.qc.ca')
|
||||||
|
from src.services.email import can_resend_verification
|
||||||
|
# Simulate recent send
|
||||||
|
user.email_verification_sent_at = datetime.utcnow()
|
||||||
|
db.session.commit()
|
||||||
|
can, remaining = can_resend_verification(user)
|
||||||
|
assert can is False
|
||||||
|
assert remaining is not None and remaining > 0
|
||||||
|
# Simulate older send (>60s) — should now allow
|
||||||
|
user.email_verification_sent_at = datetime.utcnow() - timedelta(seconds=120)
|
||||||
|
db.session.commit()
|
||||||
|
can, remaining = can_resend_verification(user)
|
||||||
|
assert can is True
|
||||||
|
assert remaining is None
|
||||||
|
finally:
|
||||||
|
db.session.rollback()
|
||||||
|
db.drop_all()
|
||||||
|
_clear_smtp_env()
|
||||||
Reference in New Issue
Block a user