Harshit Ghosh commited on
Commit Β·
9fc36aa
1
Parent(s): febc6fe
feat: implement complete password recovery flow with OTP verification and transactional email templates.
Browse files- .env.example +22 -1
- app_new.py +2 -0
- auth_routes.py +419 -27
- static/js/verify-otp.js +54 -0
- templates/auth/forgot_password.html +5 -4
- templates/auth/login.html +6 -6
- templates/auth/register.html +9 -9
- templates/auth/reset_password.html +74 -0
- templates/auth/verify_otp.html +82 -0
- templates/email/css/_base.css +160 -0
- templates/email/css/_otp.css +87 -0
- templates/email/css/_reset.css +90 -0
- templates/email/otp_email.html +144 -0
- templates/email/password_reset_email.html +138 -0
.env.example
CHANGED
|
@@ -19,7 +19,7 @@ SECRET_KEY=CHANGE_ME_IN_PRODUCTION_USE_COMMAND_ABOVE
|
|
| 19 |
# DATABASE - NEON POSTGRESQL (REQUIRED)
|
| 20 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
# Your connection string from Neon
|
| 22 |
-
DATABASE_URL=
|
| 23 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
# FILE UPLOADS & STORAGE
|
| 25 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -36,6 +36,27 @@ ICH_FOLD_SELECTION=ensemble
|
|
| 36 |
ICH_HF_MODEL_REPO=HarshCode/eff_b4_brain
|
| 37 |
ICH_HF_TOKEN=
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
# LOGGING & MONITORING
|
| 41 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 19 |
# DATABASE - NEON POSTGRESQL (REQUIRED)
|
| 20 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
# Your connection string from Neon
|
| 22 |
+
DATABASE_URL=postgresql://<username>:<password>@<host>/<database>?sslmode=require
|
| 23 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
# FILE UPLOADS & STORAGE
|
| 25 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 36 |
ICH_HF_MODEL_REPO=HarshCode/eff_b4_brain
|
| 37 |
ICH_HF_TOKEN=
|
| 38 |
|
| 39 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
# SMTP / EMAIL (REQUIRED FOR OTP + PASSWORD RESET EMAILS)
|
| 41 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 42 |
+
SMTP_HOST=smtp.your-provider.com
|
| 43 |
+
SMTP_PORT=587
|
| 44 |
+
SMTP_USER=your-email@domain.com
|
| 45 |
+
SMTP_PASSWORD=CHANGE_ME
|
| 46 |
+
SMTP_FROM=no-reply@your-domain.com
|
| 47 |
+
SMTP_USE_TLS=true
|
| 48 |
+
|
| 49 |
+
# Optional aliases also supported by code (Brevo-style):
|
| 50 |
+
# EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_FROM, EMAIL_USE_TLS
|
| 51 |
+
|
| 52 |
+
# Public base URL used in email links when app is behind proxy/load balancer.
|
| 53 |
+
# Example local: ICH_PUBLIC_BASE_URL=http://127.0.0.1:7860
|
| 54 |
+
# Example prod: ICH_PUBLIC_BASE_URL=https://your-domain.com
|
| 55 |
+
ICH_PUBLIC_BASE_URL=
|
| 56 |
+
|
| 57 |
+
# Optional local debugging for auth emails (prints OTP/reset link to server logs)
|
| 58 |
+
ICH_DEBUG_AUTH_EMAILS=false
|
| 59 |
+
|
| 60 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
# LOGGING & MONITORING
|
| 62 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
app_new.py
CHANGED
|
@@ -70,6 +70,7 @@ from flask import (
|
|
| 70 |
send_from_directory, url_for
|
| 71 |
)
|
| 72 |
from werkzeug.utils import secure_filename
|
|
|
|
| 73 |
from flask_login import current_user, login_required
|
| 74 |
|
| 75 |
# Import new security and auth modules
|
|
@@ -126,6 +127,7 @@ LOCAL_MODE = _env_bool("ICH_LOCAL_MODE", True)
|
|
| 126 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 127 |
|
| 128 |
app = Flask(__name__, template_folder="templates", static_folder="static")
|
|
|
|
| 129 |
|
| 130 |
# Configuration
|
| 131 |
app.config.update(
|
|
|
|
| 70 |
send_from_directory, url_for
|
| 71 |
)
|
| 72 |
from werkzeug.utils import secure_filename
|
| 73 |
+
from werkzeug.middleware.proxy_fix import ProxyFix
|
| 74 |
from flask_login import current_user, login_required
|
| 75 |
|
| 76 |
# Import new security and auth modules
|
|
|
|
| 127 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
|
| 129 |
app = Flask(__name__, template_folder="templates", static_folder="static")
|
| 130 |
+
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
| 131 |
|
| 132 |
# Configuration
|
| 133 |
app.config.update(
|
auth_routes.py
CHANGED
|
@@ -1,17 +1,249 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
"""
|
| 4 |
import logging
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
from
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@auth_bp.route('/register', methods=['GET', 'POST'])
|
| 17 |
def register():
|
|
@@ -60,17 +292,32 @@ def register():
|
|
| 60 |
user = User(
|
| 61 |
username=username,
|
| 62 |
email=email,
|
| 63 |
-
full_name=full_name
|
|
|
|
| 64 |
)
|
| 65 |
user.set_password(password)
|
| 66 |
|
| 67 |
db.session.add(user)
|
| 68 |
db.session.commit()
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
log_audit('user_registered', user_id=user.id, status='success')
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
except Exception as e:
|
| 76 |
db.session.rollback()
|
|
@@ -89,25 +336,44 @@ def login():
|
|
| 89 |
return redirect(url_for('home'))
|
| 90 |
|
| 91 |
if request.method == 'POST':
|
| 92 |
-
|
|
|
|
| 93 |
password = request.form.get('password', '')
|
| 94 |
-
remember = request.form.get('remember', False)
|
| 95 |
-
|
| 96 |
-
user = User.query.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
if not user:
|
| 99 |
-
logger.warning(
|
| 100 |
-
log_audit('login_failed', status='failure', details=f'User not found: {
|
| 101 |
flash('Invalid username or password', 'error')
|
| 102 |
return render_template('auth/login.html'), 401
|
| 103 |
|
| 104 |
if not user.is_active:
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
if not user.check_password(password):
|
| 110 |
-
logger.warning(
|
| 111 |
log_audit('login_failed', user_id=user.id, status='failure', details='Invalid password')
|
| 112 |
flash('Invalid username or password', 'error')
|
| 113 |
return render_template('auth/login.html'), 401
|
|
@@ -141,22 +407,147 @@ def logout():
|
|
| 141 |
|
| 142 |
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
|
| 143 |
def forgot_password():
|
| 144 |
-
"""Forgot password
|
| 145 |
if current_user.is_authenticated:
|
| 146 |
return redirect(url_for('home'))
|
| 147 |
|
| 148 |
if request.method == 'POST':
|
| 149 |
email = request.form.get('email', '').strip().lower()
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return redirect(url_for('auth.forgot_password') + '?sent=1')
|
| 155 |
|
| 156 |
return render_template('auth/forgot_password.html')
|
| 157 |
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
@auth_bp.route('/profile', methods=['GET'])
|
|
|
|
| 160 |
def profile():
|
| 161 |
"""View user profile"""
|
| 162 |
if not current_user.is_authenticated:
|
|
@@ -166,6 +557,7 @@ def profile():
|
|
| 166 |
|
| 167 |
|
| 168 |
@auth_bp.route('/change-password', methods=['POST'])
|
|
|
|
| 169 |
def change_password():
|
| 170 |
"""Change user password"""
|
| 171 |
if not current_user.is_authenticated:
|
|
|
|
| 1 |
+
"""Authentication routes: login, register, logout, password reset and OTP verification."""
|
| 2 |
+
import hashlib
|
|
|
|
| 3 |
import logging
|
| 4 |
+
import os
|
| 5 |
+
import secrets
|
| 6 |
+
import smtplib
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from email.message import EmailMessage
|
| 9 |
+
from urllib.parse import urljoin
|
| 10 |
+
|
| 11 |
+
from flask import (
|
| 12 |
+
Blueprint,
|
| 13 |
+
current_app,
|
| 14 |
+
flash,
|
| 15 |
+
jsonify,
|
| 16 |
+
redirect,
|
| 17 |
+
render_template,
|
| 18 |
+
request,
|
| 19 |
+
session,
|
| 20 |
+
url_for,
|
| 21 |
)
|
| 22 |
+
from flask_login import current_user, login_required, login_user, logout_user
|
| 23 |
+
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
| 24 |
+
from sqlalchemy import func, or_
|
| 25 |
+
|
| 26 |
+
from auth_utils import log_audit, validate_email, validate_password, validate_username
|
| 27 |
+
from models import User, db
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
| 31 |
|
| 32 |
+
OTP_SESSION_KEY = "pending_otp"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _parse_bool(raw: str | None, default: bool = False) -> bool:
|
| 36 |
+
if raw is None:
|
| 37 |
+
return default
|
| 38 |
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _auth_email_debug_enabled() -> bool:
|
| 42 |
+
return _parse_bool(os.environ.get("ICH_DEBUG_AUTH_EMAILS"), False)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _hash_otp(code: str) -> str:
|
| 46 |
+
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _generate_otp() -> str:
|
| 50 |
+
return f"{secrets.randbelow(1_000_000):06d}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _otp_payload_from_session() -> dict:
|
| 54 |
+
payload = session.get(OTP_SESSION_KEY)
|
| 55 |
+
return payload if isinstance(payload, dict) else {}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _store_otp(email: str, purpose: str, user_id: int | None = None) -> str:
|
| 59 |
+
code = _generate_otp()
|
| 60 |
+
expires_at = datetime.utcnow() + timedelta(minutes=10)
|
| 61 |
+
session[OTP_SESSION_KEY] = {
|
| 62 |
+
"email": email,
|
| 63 |
+
"purpose": purpose,
|
| 64 |
+
"user_id": user_id,
|
| 65 |
+
"otp_hash": _hash_otp(code),
|
| 66 |
+
"expires_at": expires_at.isoformat(),
|
| 67 |
+
"attempts": 0,
|
| 68 |
+
}
|
| 69 |
+
session.modified = True
|
| 70 |
+
return code
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _clear_otp() -> None:
|
| 74 |
+
session.pop(OTP_SESSION_KEY, None)
|
| 75 |
+
session.modified = True
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _extract_otp_from_form() -> str:
|
| 79 |
+
direct = request.form.get("otp", "").strip()
|
| 80 |
+
if direct:
|
| 81 |
+
return direct
|
| 82 |
+
digits = [request.form.get(f"d{i}", "").strip() for i in range(1, 7)]
|
| 83 |
+
return "".join(digits)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _validate_otp(submitted_code: str, expected_purpose: str) -> tuple[bool, str, dict | None]:
|
| 87 |
+
payload = _otp_payload_from_session()
|
| 88 |
+
if not payload:
|
| 89 |
+
return False, "OTP session is missing or expired. Please request a new code.", None
|
| 90 |
+
|
| 91 |
+
if payload.get("purpose") != expected_purpose:
|
| 92 |
+
return False, "OTP purpose mismatch. Please request a new code.", None
|
| 93 |
+
|
| 94 |
+
expires_raw = payload.get("expires_at")
|
| 95 |
+
try:
|
| 96 |
+
expires_at = datetime.fromisoformat(expires_raw)
|
| 97 |
+
except Exception:
|
| 98 |
+
_clear_otp()
|
| 99 |
+
return False, "OTP is invalid. Please request a new code.", None
|
| 100 |
+
|
| 101 |
+
if datetime.utcnow() > expires_at:
|
| 102 |
+
_clear_otp()
|
| 103 |
+
return False, "OTP expired. Please request a new code.", None
|
| 104 |
+
|
| 105 |
+
attempts = int(payload.get("attempts", 0))
|
| 106 |
+
if attempts >= 5:
|
| 107 |
+
_clear_otp()
|
| 108 |
+
return False, "Too many failed attempts. Please request a new code.", None
|
| 109 |
+
|
| 110 |
+
if _hash_otp(submitted_code) != payload.get("otp_hash"):
|
| 111 |
+
payload["attempts"] = attempts + 1
|
| 112 |
+
session[OTP_SESSION_KEY] = payload
|
| 113 |
+
session.modified = True
|
| 114 |
+
return False, "Invalid OTP code.", None
|
| 115 |
+
|
| 116 |
+
return True, "", payload
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _otp_email_content(code: str, purpose: str) -> tuple[str, str]:
|
| 120 |
+
"""Return (plain_text, html) for OTP emails."""
|
| 121 |
+
if purpose == "verify_email":
|
| 122 |
+
title = "Verify your ICH Screening account"
|
| 123 |
+
body_line = (
|
| 124 |
+
"Welcome to ICH Screening. You're one step away from accessing our platform.\n"
|
| 125 |
+
"Enter the verification code below to confirm your email address and activate your account."
|
| 126 |
+
)
|
| 127 |
+
else:
|
| 128 |
+
title = "Your ICH Screening verification code"
|
| 129 |
+
body_line = (
|
| 130 |
+
"A verification code was requested for your ICH Screening account.\n"
|
| 131 |
+
"Enter the code below to continue. If this wasn't you, your account remains secure."
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
plain = (
|
| 135 |
+
f"{title}\n"
|
| 136 |
+
f"{'=' * len(title)}\n\n"
|
| 137 |
+
f"Hi there,\n\n"
|
| 138 |
+
f"{body_line}\n\n"
|
| 139 |
+
f" Verification Code: {code}\n"
|
| 140 |
+
f" Valid for: 10 minutes\n\n"
|
| 141 |
+
"Security reminder: ICH Screening will never ask you to share this code "
|
| 142 |
+
"over the phone, email, or chat. If anyone requests it, treat it as a phishing attempt.\n\n"
|
| 143 |
+
"Didn't sign up? Simply ignore this email β your account will remain inactive "
|
| 144 |
+
"unless this code is entered.\n"
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
try:
|
| 148 |
+
from flask import render_template
|
| 149 |
+
html = render_template(
|
| 150 |
+
"email/otp_email.html",
|
| 151 |
+
title=title,
|
| 152 |
+
otp_code=code,
|
| 153 |
+
purpose=purpose,
|
| 154 |
+
recipient_name=None,
|
| 155 |
+
current_year=datetime.utcnow().year,
|
| 156 |
+
)
|
| 157 |
+
except Exception as exc:
|
| 158 |
+
logger.warning("Could not render OTP HTML email template: %s", exc)
|
| 159 |
+
html = None
|
| 160 |
+
|
| 161 |
+
return plain, html
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _password_reset_email_content(reset_link: str) -> tuple[str, str]:
|
| 165 |
+
"""Return (plain_text, html) for password-reset emails."""
|
| 166 |
+
_reset_title = "ICH Screening β Password Reset"
|
| 167 |
+
plain = (
|
| 168 |
+
f"{_reset_title}\n"
|
| 169 |
+
f"{'β' * len(_reset_title)}\n\n"
|
| 170 |
+
"Hi there,\n\n"
|
| 171 |
+
"We received a request to reset the password for your ICH Screening account.\n"
|
| 172 |
+
"Use the link below to choose a new password β it only takes a moment.\n\n"
|
| 173 |
+
f" Reset link: {reset_link}\n\n"
|
| 174 |
+
"This link is single-use and expires in 30 minutes.\n\n"
|
| 175 |
+
"Didn't request this? You can ignore this email β your password has not been\n"
|
| 176 |
+
"changed and your account remains intact.\n"
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
from flask import render_template
|
| 181 |
+
html = render_template(
|
| 182 |
+
"email/password_reset_email.html",
|
| 183 |
+
reset_link=reset_link,
|
| 184 |
+
recipient_name=None,
|
| 185 |
+
current_year=datetime.utcnow().year,
|
| 186 |
+
)
|
| 187 |
+
except Exception as exc:
|
| 188 |
+
logger.warning("Could not render password-reset HTML email template: %s", exc)
|
| 189 |
+
html = None
|
| 190 |
+
|
| 191 |
+
return plain, html
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _send_email(to_email: str, subject: str, body: str, html_body: str | None = None) -> bool:
|
| 195 |
+
"""Send a (optionally multipart HTML + plain-text) email via SMTP."""
|
| 196 |
+
smtp_host = os.environ.get("SMTP_HOST", os.environ.get("EMAIL_HOST", "")).strip()
|
| 197 |
+
smtp_user = os.environ.get("SMTP_USER", os.environ.get("EMAIL_HOST_USER", "")).strip()
|
| 198 |
+
smtp_pass = os.environ.get("SMTP_PASSWORD", os.environ.get("EMAIL_HOST_PASSWORD", "")).strip()
|
| 199 |
+
smtp_from = os.environ.get("SMTP_FROM", os.environ.get("EMAIL_FROM", smtp_user)).strip()
|
| 200 |
+
port_raw = os.environ.get("SMTP_PORT", os.environ.get("EMAIL_PORT", "587"))
|
| 201 |
+
smtp_port = int(port_raw)
|
| 202 |
+
use_tls = _parse_bool(os.environ.get("SMTP_USE_TLS", os.environ.get("EMAIL_USE_TLS")), True)
|
| 203 |
+
|
| 204 |
+
if not smtp_host or not smtp_from:
|
| 205 |
+
logger.error(
|
| 206 |
+
"SMTP not configured: set SMTP_HOST/SMTP_FROM or EMAIL_HOST/EMAIL_FROM (and credentials if required)."
|
| 207 |
+
)
|
| 208 |
+
return False
|
| 209 |
+
|
| 210 |
+
msg = EmailMessage()
|
| 211 |
+
msg["Subject"] = subject
|
| 212 |
+
msg["From"] = smtp_from
|
| 213 |
+
msg["To"] = to_email
|
| 214 |
+
# Plain-text part (always present as fallback for non-HTML clients)
|
| 215 |
+
msg.set_content(body)
|
| 216 |
+
# HTML alternative part (preferred by modern email clients when present)
|
| 217 |
+
if html_body:
|
| 218 |
+
msg.add_alternative(html_body, subtype="html")
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as server:
|
| 222 |
+
server.ehlo()
|
| 223 |
+
if use_tls:
|
| 224 |
+
server.starttls()
|
| 225 |
+
server.ehlo()
|
| 226 |
+
if smtp_user and smtp_pass:
|
| 227 |
+
server.login(smtp_user, smtp_pass)
|
| 228 |
+
server.send_message(msg)
|
| 229 |
+
return True
|
| 230 |
+
except Exception as exc:
|
| 231 |
+
logger.error("Failed to send email to %s: %s", to_email, exc)
|
| 232 |
+
return False
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _token_serializer() -> URLSafeTimedSerializer:
|
| 236 |
+
return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="ich-password-reset")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _build_external_link(endpoint: str, **values: object) -> str:
|
| 240 |
+
"""Build externally reachable link, preferring explicit public base URL when configured."""
|
| 241 |
+
public_base_url = os.environ.get("ICH_PUBLIC_BASE_URL", "").strip()
|
| 242 |
+
if public_base_url:
|
| 243 |
+
relative_path = url_for(endpoint, _external=False, **values)
|
| 244 |
+
return urljoin(public_base_url.rstrip("/") + "/", relative_path.lstrip("/"))
|
| 245 |
+
return url_for(endpoint, _external=True, **values)
|
| 246 |
+
|
| 247 |
|
| 248 |
@auth_bp.route('/register', methods=['GET', 'POST'])
|
| 249 |
def register():
|
|
|
|
| 292 |
user = User(
|
| 293 |
username=username,
|
| 294 |
email=email,
|
| 295 |
+
full_name=full_name,
|
| 296 |
+
is_active=False,
|
| 297 |
)
|
| 298 |
user.set_password(password)
|
| 299 |
|
| 300 |
db.session.add(user)
|
| 301 |
db.session.commit()
|
| 302 |
|
| 303 |
+
otp_code = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
|
| 304 |
+
_plain, _html = _otp_email_content(otp_code, "verify_email")
|
| 305 |
+
sent = _send_email(
|
| 306 |
+
user.email,
|
| 307 |
+
"Your ICH Screening verification code",
|
| 308 |
+
_plain,
|
| 309 |
+
html_body=_html,
|
| 310 |
+
)
|
| 311 |
+
if _auth_email_debug_enabled():
|
| 312 |
+
logger.info("DEV OTP for %s: %s", user.email, otp_code)
|
| 313 |
+
|
| 314 |
log_audit('user_registered', user_id=user.id, status='success')
|
| 315 |
+
if sent:
|
| 316 |
+
flash('Registration successful. We sent a verification code to your email.', 'success')
|
| 317 |
+
else:
|
| 318 |
+
flash('Registration successful, but email delivery failed. Configure SMTP and resend OTP.', 'warning')
|
| 319 |
+
|
| 320 |
+
return redirect(url_for('auth.verify_otp', purpose='verify_email', email=user.email))
|
| 321 |
|
| 322 |
except Exception as e:
|
| 323 |
db.session.rollback()
|
|
|
|
| 336 |
return redirect(url_for('home'))
|
| 337 |
|
| 338 |
if request.method == 'POST':
|
| 339 |
+
identifier = request.form.get('identifier', request.form.get('username', '')).strip()
|
| 340 |
+
normalized_identifier = identifier.lower()
|
| 341 |
password = request.form.get('password', '')
|
| 342 |
+
remember = bool(request.form.get('remember', False))
|
| 343 |
+
|
| 344 |
+
user = User.query.filter(
|
| 345 |
+
or_(
|
| 346 |
+
func.lower(User.username) == normalized_identifier,
|
| 347 |
+
func.lower(User.email) == normalized_identifier,
|
| 348 |
+
)
|
| 349 |
+
).first()
|
| 350 |
|
| 351 |
if not user:
|
| 352 |
+
logger.warning("Login attempt with non-existent identifier: %s", identifier)
|
| 353 |
+
log_audit('login_failed', status='failure', details=f'User not found: {identifier}')
|
| 354 |
flash('Invalid username or password', 'error')
|
| 355 |
return render_template('auth/login.html'), 401
|
| 356 |
|
| 357 |
if not user.is_active:
|
| 358 |
+
otp_code = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
|
| 359 |
+
_plain, _html = _otp_email_content(otp_code, "verify_email")
|
| 360 |
+
sent = _send_email(
|
| 361 |
+
user.email,
|
| 362 |
+
"Your ICH Screening verification code",
|
| 363 |
+
_plain,
|
| 364 |
+
html_body=_html,
|
| 365 |
+
)
|
| 366 |
+
if _auth_email_debug_enabled():
|
| 367 |
+
logger.info("DEV OTP resend/login for %s: %s", user.email, otp_code)
|
| 368 |
+
log_audit('login_failed', user_id=user.id, status='failure', details='Email not verified')
|
| 369 |
+
if sent:
|
| 370 |
+
flash('Please verify your email. A fresh OTP code was sent.', 'info')
|
| 371 |
+
else:
|
| 372 |
+
flash('Please verify your email. OTP generation worked but SMTP is not configured.', 'warning')
|
| 373 |
+
return redirect(url_for('auth.verify_otp', purpose='verify_email', email=user.email))
|
| 374 |
|
| 375 |
if not user.check_password(password):
|
| 376 |
+
logger.warning("Failed login attempt for identifier: %s", identifier)
|
| 377 |
log_audit('login_failed', user_id=user.id, status='failure', details='Invalid password')
|
| 378 |
flash('Invalid username or password', 'error')
|
| 379 |
return render_template('auth/login.html'), 401
|
|
|
|
| 407 |
|
| 408 |
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
|
| 409 |
def forgot_password():
|
| 410 |
+
"""Forgot password route: issue signed reset link and send email."""
|
| 411 |
if current_user.is_authenticated:
|
| 412 |
return redirect(url_for('home'))
|
| 413 |
|
| 414 |
if request.method == 'POST':
|
| 415 |
email = request.form.get('email', '').strip().lower()
|
| 416 |
+
user = User.query.filter_by(email=email).first()
|
| 417 |
+
|
| 418 |
+
if user:
|
| 419 |
+
token = _token_serializer().dumps({"email": user.email, "purpose": "reset_password"})
|
| 420 |
+
reset_link = _build_external_link('auth.reset_password', token=token)
|
| 421 |
+
_plain, _html = _password_reset_email_content(reset_link)
|
| 422 |
+
sent = _send_email(
|
| 423 |
+
user.email,
|
| 424 |
+
'Reset your ICH Screening password',
|
| 425 |
+
_plain,
|
| 426 |
+
html_body=_html,
|
| 427 |
+
)
|
| 428 |
+
if _auth_email_debug_enabled():
|
| 429 |
+
logger.info("DEV reset link for %s: %s", user.email, reset_link)
|
| 430 |
+
log_audit(
|
| 431 |
+
'password_reset_requested',
|
| 432 |
+
user_id=user.id,
|
| 433 |
+
status='success' if sent else 'failure',
|
| 434 |
+
details='Reset email sent' if sent else 'SMTP failed',
|
| 435 |
+
)
|
| 436 |
+
else:
|
| 437 |
+
log_audit('password_reset_requested', status='info', details=f'Unknown email: {email}')
|
| 438 |
+
flash('No account exists with this email address.', 'error')
|
| 439 |
+
return render_template('auth/forgot_password.html'), 404
|
| 440 |
+
|
| 441 |
return redirect(url_for('auth.forgot_password') + '?sent=1')
|
| 442 |
|
| 443 |
return render_template('auth/forgot_password.html')
|
| 444 |
|
| 445 |
|
| 446 |
+
@auth_bp.route('/verify-otp', methods=['GET', 'POST'])
|
| 447 |
+
def verify_otp():
|
| 448 |
+
"""Verify one-time password for account email verification."""
|
| 449 |
+
purpose = request.args.get('purpose', 'verify_email')
|
| 450 |
+
payload = _otp_payload_from_session()
|
| 451 |
+
email = payload.get('email') or request.args.get('email', '')
|
| 452 |
+
|
| 453 |
+
if request.method == 'POST':
|
| 454 |
+
submitted = _extract_otp_from_form()
|
| 455 |
+
if len(submitted) != 6 or not submitted.isdigit():
|
| 456 |
+
flash('Please enter the 6-digit OTP code.', 'error')
|
| 457 |
+
return render_template('auth/verify_otp.html', email=email, purpose=purpose), 400
|
| 458 |
+
|
| 459 |
+
ok, msg, verified_payload = _validate_otp(submitted, purpose)
|
| 460 |
+
if not ok:
|
| 461 |
+
flash(msg, 'error')
|
| 462 |
+
return render_template('auth/verify_otp.html', email=email, purpose=purpose), 400
|
| 463 |
+
|
| 464 |
+
user_id = verified_payload.get("user_id") if verified_payload else None
|
| 465 |
+
user = User.query.get(int(user_id)) if user_id else None
|
| 466 |
+
if not user:
|
| 467 |
+
_clear_otp()
|
| 468 |
+
flash('Verification session is invalid. Please register again.', 'error')
|
| 469 |
+
return redirect(url_for('auth.register'))
|
| 470 |
+
|
| 471 |
+
user.is_active = True
|
| 472 |
+
db.session.commit()
|
| 473 |
+
_clear_otp()
|
| 474 |
+
log_audit('email_verified', user_id=user.id, status='success')
|
| 475 |
+
flash('Email verified. You can now sign in.', 'success')
|
| 476 |
+
return redirect(url_for('auth.login'))
|
| 477 |
+
|
| 478 |
+
return render_template('auth/verify_otp.html', email=email, purpose=purpose)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
@auth_bp.route('/resend-otp', methods=['POST'])
|
| 482 |
+
def resend_otp():
|
| 483 |
+
"""Resend OTP for the current verification session."""
|
| 484 |
+
payload = _otp_payload_from_session()
|
| 485 |
+
if not payload:
|
| 486 |
+
flash('No active OTP session. Please register again.', 'error')
|
| 487 |
+
return redirect(url_for('auth.register'))
|
| 488 |
+
|
| 489 |
+
email = payload.get("email", "")
|
| 490 |
+
purpose = payload.get("purpose", "verify_email")
|
| 491 |
+
user_id = payload.get("user_id")
|
| 492 |
+
new_code = _store_otp(email=email, purpose=purpose, user_id=user_id)
|
| 493 |
+
_plain, _html = _otp_email_content(new_code, purpose)
|
| 494 |
+
sent = _send_email(email, "Your ICH Screening verification code", _plain, html_body=_html)
|
| 495 |
+
if _auth_email_debug_enabled():
|
| 496 |
+
logger.info("DEV OTP resend for %s: %s", email, new_code)
|
| 497 |
+
|
| 498 |
+
if sent:
|
| 499 |
+
flash('A new OTP code was sent to your email.', 'success')
|
| 500 |
+
else:
|
| 501 |
+
flash('Failed to send OTP email. Please configure SMTP settings.', 'error')
|
| 502 |
+
return redirect(url_for('auth.verify_otp', purpose=purpose, email=email))
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
| 506 |
+
def reset_password(token: str):
|
| 507 |
+
"""Reset password using signed token sent by email."""
|
| 508 |
+
try:
|
| 509 |
+
payload = _token_serializer().loads(token, max_age=1800)
|
| 510 |
+
except SignatureExpired:
|
| 511 |
+
flash('Password reset link has expired. Please request a new one.', 'error')
|
| 512 |
+
return redirect(url_for('auth.forgot_password'))
|
| 513 |
+
except BadSignature:
|
| 514 |
+
flash('Invalid password reset link.', 'error')
|
| 515 |
+
return redirect(url_for('auth.forgot_password'))
|
| 516 |
+
|
| 517 |
+
if payload.get('purpose') != 'reset_password':
|
| 518 |
+
flash('Invalid password reset link.', 'error')
|
| 519 |
+
return redirect(url_for('auth.forgot_password'))
|
| 520 |
+
|
| 521 |
+
email = payload.get('email', '').strip().lower()
|
| 522 |
+
user = User.query.filter_by(email=email).first()
|
| 523 |
+
if not user:
|
| 524 |
+
flash('Invalid password reset link.', 'error')
|
| 525 |
+
return redirect(url_for('auth.forgot_password'))
|
| 526 |
+
|
| 527 |
+
if request.method == 'POST':
|
| 528 |
+
password = request.form.get('password', '')
|
| 529 |
+
confirm_password = request.form.get('confirm_password', '')
|
| 530 |
+
|
| 531 |
+
if password != confirm_password:
|
| 532 |
+
flash('Passwords do not match.', 'error')
|
| 533 |
+
return render_template('auth/reset_password.html', token=token), 400
|
| 534 |
+
|
| 535 |
+
valid, msg = validate_password(password)
|
| 536 |
+
if not valid:
|
| 537 |
+
flash(msg, 'error')
|
| 538 |
+
return render_template('auth/reset_password.html', token=token), 400
|
| 539 |
+
|
| 540 |
+
user.set_password(password)
|
| 541 |
+
db.session.commit()
|
| 542 |
+
log_audit('password_reset_completed', user_id=user.id, status='success')
|
| 543 |
+
flash('Password updated successfully. Please sign in.', 'success')
|
| 544 |
+
return redirect(url_for('auth.login'))
|
| 545 |
+
|
| 546 |
+
return render_template('auth/reset_password.html', token=token)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
@auth_bp.route('/profile', methods=['GET'])
|
| 550 |
+
@login_required
|
| 551 |
def profile():
|
| 552 |
"""View user profile"""
|
| 553 |
if not current_user.is_authenticated:
|
|
|
|
| 557 |
|
| 558 |
|
| 559 |
@auth_bp.route('/change-password', methods=['POST'])
|
| 560 |
+
@login_required
|
| 561 |
def change_password():
|
| 562 |
"""Change user password"""
|
| 563 |
if not current_user.is_authenticated:
|
static/js/verify-otp.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
document.addEventListener('DOMContentLoaded', function () {
|
| 2 |
+
var form = document.getElementById('otpForm');
|
| 3 |
+
var combined = document.getElementById('otpCombined');
|
| 4 |
+
var inputs = Array.prototype.slice.call(document.querySelectorAll('.otp-digit'));
|
| 5 |
+
|
| 6 |
+
if (!form || !combined || !inputs.length) {
|
| 7 |
+
return;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function normalize(v) {
|
| 11 |
+
return (v || '').replace(/\D/g, '').slice(0, 1);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function updateCombined() {
|
| 15 |
+
combined.value = inputs.map(function (i) { return i.value; }).join('');
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
inputs.forEach(function (input, idx) {
|
| 19 |
+
input.addEventListener('input', function () {
|
| 20 |
+
input.value = normalize(input.value);
|
| 21 |
+
updateCombined();
|
| 22 |
+
if (input.value && idx < inputs.length - 1) {
|
| 23 |
+
inputs[idx + 1].focus();
|
| 24 |
+
}
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
input.addEventListener('keydown', function (event) {
|
| 28 |
+
if (event.key === 'Backspace' && !input.value && idx > 0) {
|
| 29 |
+
inputs[idx - 1].focus();
|
| 30 |
+
}
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
input.addEventListener('paste', function (event) {
|
| 34 |
+
var text = (event.clipboardData || window.clipboardData).getData('text');
|
| 35 |
+
var digits = (text || '').replace(/\D/g, '').slice(0, 6).split('');
|
| 36 |
+
if (!digits.length) {
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
event.preventDefault();
|
| 40 |
+
inputs.forEach(function (box, i) {
|
| 41 |
+
box.value = digits[i] || '';
|
| 42 |
+
});
|
| 43 |
+
updateCombined();
|
| 44 |
+
var last = Math.min(digits.length - 1, inputs.length - 1);
|
| 45 |
+
inputs[last].focus();
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
form.addEventListener('submit', function () {
|
| 50 |
+
updateCombined();
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
inputs[0].focus();
|
| 54 |
+
});
|
templates/auth/forgot_password.html
CHANGED
|
@@ -78,8 +78,8 @@
|
|
| 78 |
<main class="auth-form-panel">
|
| 79 |
<div class="auth-card" id="formCard">
|
| 80 |
<div class="auth-card-header">
|
| 81 |
-
<h2>Forgot password?</h2>
|
| 82 |
-
<p>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
@@ -106,7 +106,8 @@
|
|
| 106 |
</div>
|
| 107 |
<h3 style="color:#e8ecf6;font-size:1.2rem;font-weight:800;margin-bottom:10px;">Check your inbox</h3>
|
| 108 |
<p style="color:#8ba0c4;font-size:.9rem;line-height:1.65;margin-bottom:24px;">
|
| 109 |
-
If that
|
|
|
|
| 110 |
</p>
|
| 111 |
<a href="{{ url_for('auth.login') }}" class="btn-auth-submit" style="display:block;text-decoration:none;text-align:center;">
|
| 112 |
Back to Sign In
|
|
@@ -127,7 +128,7 @@
|
|
| 127 |
</form>
|
| 128 |
|
| 129 |
<div class="auth-footer">
|
| 130 |
-
|
| 131 |
</div>
|
| 132 |
</div>
|
| 133 |
</main>
|
|
|
|
| 78 |
<main class="auth-form-panel">
|
| 79 |
<div class="auth-card" id="formCard">
|
| 80 |
<div class="auth-card-header">
|
| 81 |
+
<h2>Forgot your password?</h2>
|
| 82 |
+
<p>No problem β enter your email and we'll send you a reset link</p>
|
| 83 |
</div>
|
| 84 |
|
| 85 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
|
|
| 106 |
</div>
|
| 107 |
<h3 style="color:#e8ecf6;font-size:1.2rem;font-weight:800;margin-bottom:10px;">Check your inbox</h3>
|
| 108 |
<p style="color:#8ba0c4;font-size:.9rem;line-height:1.65;margin-bottom:24px;">
|
| 109 |
+
If that address is registered with us, a reset link is on its way. It expires in 30 minutes, so act quickly.
|
| 110 |
+
Don't see it? Check your spam or junk folder.
|
| 111 |
</p>
|
| 112 |
<a href="{{ url_for('auth.login') }}" class="btn-auth-submit" style="display:block;text-decoration:none;text-align:center;">
|
| 113 |
Back to Sign In
|
|
|
|
| 128 |
</form>
|
| 129 |
|
| 130 |
<div class="auth-footer">
|
| 131 |
+
Remembered it after all? <a href="{{ url_for('auth.login') }}">Back to sign in</a>
|
| 132 |
</div>
|
| 133 |
</div>
|
| 134 |
</main>
|
templates/auth/login.html
CHANGED
|
@@ -27,7 +27,7 @@
|
|
| 27 |
|
| 28 |
<div class="auth-headline">
|
| 29 |
<h2>AI-Powered <span class="grad">Hemorrhage</span> Detection</h2>
|
| 30 |
-
<p>Clinical-grade CT scan analysis with Grad-CAM explainability
|
| 31 |
</div>
|
| 32 |
|
| 33 |
<ul class="auth-features">
|
|
@@ -131,7 +131,7 @@
|
|
| 131 |
<div class="auth-card">
|
| 132 |
<div class="auth-card-header">
|
| 133 |
<h2>Welcome back</h2>
|
| 134 |
-
<p>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
@@ -153,11 +153,11 @@
|
|
| 153 |
|
| 154 |
<form method="POST" class="auth-form" id="loginForm">
|
| 155 |
<div class="form-group">
|
| 156 |
-
<label for="
|
| 157 |
<div class="input-wrap">
|
| 158 |
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
| 159 |
-
<input type="text" id="
|
| 160 |
-
placeholder="Enter your username" autocomplete="username"/>
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
|
|
@@ -185,7 +185,7 @@
|
|
| 185 |
</form>
|
| 186 |
|
| 187 |
<div class="auth-footer">
|
| 188 |
-
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
</main>
|
|
|
|
| 27 |
|
| 28 |
<div class="auth-headline">
|
| 29 |
<h2>AI-Powered <span class="grad">Hemorrhage</span> Detection</h2>
|
| 30 |
+
<p>Clinical-grade CT scan analysis with Grad-CAM explainability β built for speed, precision, and trust.</p>
|
| 31 |
</div>
|
| 32 |
|
| 33 |
<ul class="auth-features">
|
|
|
|
| 131 |
<div class="auth-card">
|
| 132 |
<div class="auth-card-header">
|
| 133 |
<h2>Welcome back</h2>
|
| 134 |
+
<p>Enter your credentials to access your dashboard</p>
|
| 135 |
</div>
|
| 136 |
|
| 137 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
|
|
| 153 |
|
| 154 |
<form method="POST" class="auth-form" id="loginForm">
|
| 155 |
<div class="form-group">
|
| 156 |
+
<label for="identifier">Username or Email</label>
|
| 157 |
<div class="input-wrap">
|
| 158 |
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
| 159 |
+
<input type="text" id="identifier" name="identifier" required autofocus
|
| 160 |
+
placeholder="Enter your username or email" autocomplete="username"/>
|
| 161 |
</div>
|
| 162 |
</div>
|
| 163 |
|
|
|
|
| 185 |
</form>
|
| 186 |
|
| 187 |
<div class="auth-footer">
|
| 188 |
+
New to ICH Screening? <a href="{{ url_for('auth.register') }}">Create a free account</a>
|
| 189 |
</div>
|
| 190 |
</div>
|
| 191 |
</main>
|
templates/auth/register.html
CHANGED
|
@@ -35,25 +35,25 @@
|
|
| 35 |
<span class="feat-icon">
|
| 36 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
| 37 |
</span>
|
| 38 |
-
Free to get started
|
| 39 |
</li>
|
| 40 |
<li>
|
| 41 |
<span class="feat-icon">
|
| 42 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
| 43 |
</span>
|
| 44 |
-
Your scans
|
| 45 |
</li>
|
| 46 |
<li>
|
| 47 |
<span class="feat-icon">
|
| 48 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
| 49 |
</span>
|
| 50 |
-
Full calibration metrics
|
| 51 |
</li>
|
| 52 |
<li>
|
| 53 |
<span class="feat-icon">
|
| 54 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 55 |
</span>
|
| 56 |
-
|
| 57 |
</li>
|
| 58 |
</ul>
|
| 59 |
|
|
@@ -122,8 +122,8 @@
|
|
| 122 |
<main class="auth-form-panel">
|
| 123 |
<div class="auth-card">
|
| 124 |
<div class="auth-card-header">
|
| 125 |
-
<h2>Create account</h2>
|
| 126 |
-
<p>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
@@ -152,7 +152,7 @@
|
|
| 152 |
<input type="text" id="username" name="username" required autofocus
|
| 153 |
placeholder="3β80 chars, letters/numbers/-/_" autocomplete="username"/>
|
| 154 |
</div>
|
| 155 |
-
<span class="form-hint">Letters, numbers, hyphens and underscores only</span>
|
| 156 |
</div>
|
| 157 |
|
| 158 |
<!-- Email -->
|
|
@@ -181,7 +181,7 @@
|
|
| 181 |
<div class="input-wrap">
|
| 182 |
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
| 183 |
<input type="password" id="password" name="password" required
|
| 184 |
-
class="has-toggle" placeholder="8
|
| 185 |
<button type="button" class="btn-pw-toggle" id="togglePw" aria-label="Toggle password visibility">
|
| 186 |
<svg id="eyeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
| 187 |
</button>
|
|
@@ -207,7 +207,7 @@
|
|
| 207 |
</form>
|
| 208 |
|
| 209 |
<div class="auth-footer">
|
| 210 |
-
Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a>
|
| 211 |
</div>
|
| 212 |
</div>
|
| 213 |
</main>
|
|
|
|
| 35 |
<span class="feat-icon">
|
| 36 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
| 37 |
</span>
|
| 38 |
+
Free to get started β no credit card needed
|
| 39 |
</li>
|
| 40 |
<li>
|
| 41 |
<span class="feat-icon">
|
| 42 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
| 43 |
</span>
|
| 44 |
+
Your scans are private and isolated
|
| 45 |
</li>
|
| 46 |
<li>
|
| 47 |
<span class="feat-icon">
|
| 48 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
| 49 |
</span>
|
| 50 |
+
Full calibration metrics and PDF reports
|
| 51 |
</li>
|
| 52 |
<li>
|
| 53 |
<span class="feat-icon">
|
| 54 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
| 55 |
</span>
|
| 56 |
+
AI results delivered in seconds
|
| 57 |
</li>
|
| 58 |
</ul>
|
| 59 |
|
|
|
|
| 122 |
<main class="auth-form-panel">
|
| 123 |
<div class="auth-card">
|
| 124 |
<div class="auth-card-header">
|
| 125 |
+
<h2>Create your account</h2>
|
| 126 |
+
<p>Takes less than a minute to get started</p>
|
| 127 |
</div>
|
| 128 |
|
| 129 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
|
|
| 152 |
<input type="text" id="username" name="username" required autofocus
|
| 153 |
placeholder="3β80 chars, letters/numbers/-/_" autocomplete="username"/>
|
| 154 |
</div>
|
| 155 |
+
<span class="form-hint">Letters, numbers, hyphens and underscores only (3β80 characters)</span>
|
| 156 |
</div>
|
| 157 |
|
| 158 |
<!-- Email -->
|
|
|
|
| 181 |
<div class="input-wrap">
|
| 182 |
<svg class="input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
| 183 |
<input type="password" id="password" name="password" required
|
| 184 |
+
class="has-toggle" placeholder="Min. 8 chars with uppercase, lowercase & number" autocomplete="new-password"/>
|
| 185 |
<button type="button" class="btn-pw-toggle" id="togglePw" aria-label="Toggle password visibility">
|
| 186 |
<svg id="eyeIcon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
| 187 |
</button>
|
|
|
|
| 207 |
</form>
|
| 208 |
|
| 209 |
<div class="auth-footer">
|
| 210 |
+
Already have an account? <a href="{{ url_for('auth.login') }}">Sign in instead</a>
|
| 211 |
</div>
|
| 212 |
</div>
|
| 213 |
</main>
|
templates/auth/reset_password.html
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Reset Password β ICH Screening</title>
|
| 7 |
+
<meta name="description" content="Set a new password for your account."/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
| 11 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}"/>
|
| 12 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"/>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div class="auth-page">
|
| 16 |
+
<aside class="auth-brand">
|
| 17 |
+
<div class="auth-brand-logo">
|
| 18 |
+
<div class="auth-brand-icon">
|
| 19 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 20 |
+
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
| 21 |
+
</svg>
|
| 22 |
+
</div>
|
| 23 |
+
<span class="auth-brand-name">ICH Screening</span>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="auth-headline">
|
| 27 |
+
<h2>Almost <span class="grad">Back In</span></h2>
|
| 28 |
+
<p>Choose a strong, unique password you haven't used before.</p>
|
| 29 |
+
</div>
|
| 30 |
+
</aside>
|
| 31 |
+
|
| 32 |
+
<main class="auth-form-panel">
|
| 33 |
+
<div class="auth-card">
|
| 34 |
+
<div class="auth-card-header">
|
| 35 |
+
<h2>Choose a new password</h2>
|
| 36 |
+
<p>This link is single-use and expires in 30 minutes</p>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 40 |
+
{% if messages %}
|
| 41 |
+
<div class="auth-alerts">
|
| 42 |
+
{% for category, message in messages %}
|
| 43 |
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
| 44 |
+
{% endfor %}
|
| 45 |
+
</div>
|
| 46 |
+
{% endif %}
|
| 47 |
+
{% endwith %}
|
| 48 |
+
|
| 49 |
+
<form method="POST" class="auth-form" id="resetPwForm">
|
| 50 |
+
<div class="form-group">
|
| 51 |
+
<label for="password">New Password</label>
|
| 52 |
+
<div class="input-wrap">
|
| 53 |
+
<input type="password" id="password" name="password" required class="has-toggle" minlength="8" autocomplete="new-password"/>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="form-group">
|
| 58 |
+
<label for="confirm_password">Confirm New Password</label>
|
| 59 |
+
<div class="input-wrap">
|
| 60 |
+
<input type="password" id="confirm_password" name="confirm_password" required class="has-toggle" minlength="8" autocomplete="new-password"/>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<button type="submit" class="btn-auth-submit">Save New Password</button>
|
| 65 |
+
</form>
|
| 66 |
+
|
| 67 |
+
<div class="auth-footer">
|
| 68 |
+
Remembered it? <a href="{{ url_for('auth.login') }}">Back to sign in</a>
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
</main>
|
| 72 |
+
</div>
|
| 73 |
+
</body>
|
| 74 |
+
</html>
|
templates/auth/verify_otp.html
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Verify Email OTP β ICH Screening</title>
|
| 7 |
+
<meta name="description" content="Verify your email with one-time password."/>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
| 11 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/base.css') }}"/>
|
| 12 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}"/>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div class="auth-page">
|
| 16 |
+
<aside class="auth-brand">
|
| 17 |
+
<div class="auth-brand-logo">
|
| 18 |
+
<div class="auth-brand-icon">
|
| 19 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 20 |
+
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
| 21 |
+
</svg>
|
| 22 |
+
</div>
|
| 23 |
+
<span class="auth-brand-name">ICH Screening</span>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
+
<div class="auth-headline">
|
| 27 |
+
<h2>Check Your <span class="grad">Email</span></h2>
|
| 28 |
+
<p>We sent a 6-digit code to your inbox. Enter it below to verify your identity and activate your account.</p>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<ul class="auth-features">
|
| 32 |
+
<li><span class="feat-icon">1</span>Open the email from ICH Screening</li>
|
| 33 |
+
<li><span class="feat-icon">2</span>Copy the 6-digit code</li>
|
| 34 |
+
<li><span class="feat-icon">3</span>Enter it here β valid for 10 minutes</li>
|
| 35 |
+
</ul>
|
| 36 |
+
</aside>
|
| 37 |
+
|
| 38 |
+
<main class="auth-form-panel">
|
| 39 |
+
<div class="auth-card">
|
| 40 |
+
<div class="auth-card-header">
|
| 41 |
+
<h2>Enter your verification code</h2>
|
| 42 |
+
<p>Sent to <strong>{{ email or 'your registered email' }}</strong></p>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 46 |
+
{% if messages %}
|
| 47 |
+
<div class="auth-alerts">
|
| 48 |
+
{% for category, message in messages %}
|
| 49 |
+
<div class="alert alert-{{ category }}">{{ message }}</div>
|
| 50 |
+
{% endfor %}
|
| 51 |
+
</div>
|
| 52 |
+
{% endif %}
|
| 53 |
+
{% endwith %}
|
| 54 |
+
|
| 55 |
+
<form method="POST" class="auth-form" id="otpForm">
|
| 56 |
+
<input type="hidden" name="otp" id="otpCombined"/>
|
| 57 |
+
<div class="otp-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;">
|
| 58 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d1" class="otp-digit" required/>
|
| 59 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d2" class="otp-digit" required/>
|
| 60 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d3" class="otp-digit" required/>
|
| 61 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d4" class="otp-digit" required/>
|
| 62 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d5" class="otp-digit" required/>
|
| 63 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d6" class="otp-digit" required/>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<button type="submit" class="btn-auth-submit" style="margin-top:16px;">Verify & Activate Account</button>
|
| 67 |
+
</form>
|
| 68 |
+
|
| 69 |
+
<form method="POST" action="{{ url_for('auth.resend_otp') }}" style="margin-top:12px;">
|
| 70 |
+
<button type="submit" class="btn-auth-submit" style="background:#0f1b31;border:1px solid #2a3f68;">Didn't receive it? Resend code</button>
|
| 71 |
+
</form>
|
| 72 |
+
|
| 73 |
+
<div class="auth-footer">
|
| 74 |
+
Wrong account? <a href="{{ url_for('auth.login') }}">Back to sign in</a>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
</main>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
<script src="{{ url_for('static', filename='js/verify-otp.js') }}" defer></script>
|
| 81 |
+
</body>
|
| 82 |
+
</html>
|
templates/email/css/_base.css
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
EMAIL BASE STYLES
|
| 3 |
+
Shared across all ICH Screening email templates.
|
| 4 |
+
Included via Jinja2: {% include 'email/css/_base.css' %}
|
| 5 |
+
NOTE: Email clients do NOT load external stylesheets.
|
| 6 |
+
This file is injected inline at render time by Flask.
|
| 7 |
+
============================================================ */
|
| 8 |
+
|
| 9 |
+
/* ββ Reset ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 10 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 11 |
+
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
| 12 |
+
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; border-collapse: collapse; }
|
| 13 |
+
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; }
|
| 14 |
+
a { text-decoration: none; }
|
| 15 |
+
|
| 16 |
+
/* ββ Color Scheme Declaration βββββββββββββββββββββββββββββ */
|
| 17 |
+
:root {
|
| 18 |
+
color-scheme: light dark;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/* ββ LIGHT THEME (default) ββββββββββββββββββββββββββββββββ */
|
| 22 |
+
body {
|
| 23 |
+
background-color: #f0f4f8;
|
| 24 |
+
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
| 25 |
+
color: #1a202c;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.email-wrapper {
|
| 29 |
+
width: 100%;
|
| 30 |
+
background-color: #f0f4f8;
|
| 31 |
+
padding: 40px 16px;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.email-container {
|
| 35 |
+
max-width: 560px;
|
| 36 |
+
margin: 0 auto;
|
| 37 |
+
background-color: #ffffff;
|
| 38 |
+
border-radius: 16px;
|
| 39 |
+
overflow: hidden;
|
| 40 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 44 |
+
.email-header {
|
| 45 |
+
background: linear-gradient(135deg, #0d1b3e 0%, #0a2a4a 50%, #0d1b3e 100%);
|
| 46 |
+
padding: 40px 32px 36px;
|
| 47 |
+
text-align: center;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.brand-icon {
|
| 51 |
+
width: 36px;
|
| 52 |
+
height: 36px;
|
| 53 |
+
background: rgba(0, 212, 255, 0.15);
|
| 54 |
+
border: 1.5px solid rgba(0, 212, 255, 0.5);
|
| 55 |
+
border-radius: 10px;
|
| 56 |
+
display: inline-flex;
|
| 57 |
+
align-items: center;
|
| 58 |
+
justify-content: center;
|
| 59 |
+
vertical-align: middle;
|
| 60 |
+
margin-right: 10px;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.brand-name {
|
| 64 |
+
font-size: 15px;
|
| 65 |
+
font-weight: 700;
|
| 66 |
+
color: #ffffff;
|
| 67 |
+
letter-spacing: 0.5px;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.header-title {
|
| 71 |
+
font-size: 22px;
|
| 72 |
+
font-weight: 700;
|
| 73 |
+
color: #ffffff;
|
| 74 |
+
margin-top: 16px;
|
| 75 |
+
letter-spacing: -0.3px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.header-subtitle {
|
| 79 |
+
font-size: 14px;
|
| 80 |
+
color: rgba(255, 255, 255, 0.6);
|
| 81 |
+
margin-top: 6px;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* ββ Body ββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 85 |
+
.email-body {
|
| 86 |
+
padding: 36px 32px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.greeting {
|
| 90 |
+
font-size: 15px;
|
| 91 |
+
color: #4a5568;
|
| 92 |
+
line-height: 1.7;
|
| 93 |
+
margin-bottom: 28px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.divider {
|
| 97 |
+
border: none;
|
| 98 |
+
border-top: 1px solid #e2e8f0;
|
| 99 |
+
margin: 24px 0;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.security-notice {
|
| 103 |
+
font-size: 12px;
|
| 104 |
+
color: #a0aec0;
|
| 105 |
+
line-height: 1.7;
|
| 106 |
+
margin-bottom: 8px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* ββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 110 |
+
.email-footer {
|
| 111 |
+
background-color: #f7f8fb;
|
| 112 |
+
border-top: 1px solid #e2e8f0;
|
| 113 |
+
padding: 20px 32px;
|
| 114 |
+
text-align: center;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.footer-brand {
|
| 118 |
+
font-size: 13px;
|
| 119 |
+
font-weight: 700;
|
| 120 |
+
color: #4a5568;
|
| 121 |
+
margin-bottom: 4px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.footer-text {
|
| 125 |
+
font-size: 11px;
|
| 126 |
+
color: #a0aec0;
|
| 127 |
+
line-height: 1.6;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* ββ DARK THEME (shared overrides) ββββββββββββββββββββββββ */
|
| 131 |
+
@media (prefers-color-scheme: dark) {
|
| 132 |
+
body { background-color: #0b0f1a !important; color: #e2e8f0 !important; }
|
| 133 |
+
|
| 134 |
+
.email-wrapper { background-color: #0b0f1a !important; }
|
| 135 |
+
|
| 136 |
+
.email-container {
|
| 137 |
+
background-color: #111827 !important;
|
| 138 |
+
box-shadow: 0 4px 32px rgba(0, 0, 0, 0.5) !important;
|
| 139 |
+
border: 1px solid rgba(255, 255, 255, 0.06) !important;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.email-header {
|
| 143 |
+
background: linear-gradient(135deg, #060d1f 0%, #081428 50%, #060d1f 100%) !important;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.greeting { color: #a0aec0 !important; }
|
| 147 |
+
.security-notice { color: #4a5568 !important; }
|
| 148 |
+
.divider { border-top-color: rgba(255, 255, 255, 0.07) !important; }
|
| 149 |
+
|
| 150 |
+
.email-footer { background-color: #0d1424 !important; border-top-color: rgba(255, 255, 255, 0.07) !important; }
|
| 151 |
+
.footer-brand { color: #718096 !important; }
|
| 152 |
+
.footer-text { color: #4a5568 !important; }
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/* ββ RESPONSIVE (shared) βββββββββββββββββββββββββββββββββββ */
|
| 156 |
+
@media only screen and (max-width: 560px) {
|
| 157 |
+
.email-body { padding: 28px 20px !important; }
|
| 158 |
+
.email-header { padding: 32px 20px 28px !important; }
|
| 159 |
+
.email-footer { padding: 16px 20px !important; }
|
| 160 |
+
}
|
templates/email/css/_otp.css
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
OTP EMAIL β COMPONENT STYLES
|
| 3 |
+
Styles specific to the OTP/email-verification email template.
|
| 4 |
+
Included via Jinja2: {% include 'email/css/_otp.css' %}
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
/* ββ OTP Label βββββββββββββββββββββββββββββββββββββββββββββ */
|
| 8 |
+
.otp-label {
|
| 9 |
+
font-size: 11px;
|
| 10 |
+
font-weight: 700;
|
| 11 |
+
letter-spacing: 1.5px;
|
| 12 |
+
text-transform: uppercase;
|
| 13 |
+
color: #718096;
|
| 14 |
+
margin-bottom: 12px;
|
| 15 |
+
text-align: center;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* ββ OTP Box βββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 19 |
+
.otp-box {
|
| 20 |
+
background: #f7f8fb;
|
| 21 |
+
border: 2px solid #e2e8f0;
|
| 22 |
+
border-radius: 14px;
|
| 23 |
+
padding: 28px 20px;
|
| 24 |
+
text-align: center;
|
| 25 |
+
margin-bottom: 28px;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.otp-digits {
|
| 29 |
+
font-family: 'Courier New', Courier, monospace;
|
| 30 |
+
font-size: 42px;
|
| 31 |
+
font-weight: 800;
|
| 32 |
+
letter-spacing: 12px;
|
| 33 |
+
color: #0d1b3e;
|
| 34 |
+
text-indent: 12px; /* compensates for letter-spacing gap on last char */
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.otp-accent {
|
| 38 |
+
color: #0078d4;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.otp-expiry {
|
| 42 |
+
font-size: 12px;
|
| 43 |
+
color: #718096;
|
| 44 |
+
margin-top: 10px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.otp-expiry strong {
|
| 48 |
+
color: #e53e3e;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/* ββ Info Block ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 52 |
+
.info-block {
|
| 53 |
+
background: #ebf8ff;
|
| 54 |
+
border-left: 4px solid #0078d4;
|
| 55 |
+
border-radius: 0 8px 8px 0;
|
| 56 |
+
padding: 14px 16px;
|
| 57 |
+
margin-bottom: 24px;
|
| 58 |
+
font-size: 13px;
|
| 59 |
+
color: #2c5282;
|
| 60 |
+
line-height: 1.6;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/* ββ DARK THEME (OTP-specific overrides) βββββββββββββββββββ */
|
| 64 |
+
@media (prefers-color-scheme: dark) {
|
| 65 |
+
.otp-label { color: #4a5568 !important; }
|
| 66 |
+
|
| 67 |
+
.otp-box { background: #1a2035 !important; border-color: rgba(0, 212, 255, 0.25) !important; }
|
| 68 |
+
.otp-digits { color: #e2e8f0 !important; }
|
| 69 |
+
.otp-accent { color: #00d4ff !important; }
|
| 70 |
+
.otp-expiry { color: #718096 !important; }
|
| 71 |
+
.otp-expiry strong { color: #fc8181 !important; }
|
| 72 |
+
|
| 73 |
+
.info-block {
|
| 74 |
+
background: rgba(0, 120, 212, 0.1) !important;
|
| 75 |
+
border-left-color: #00d4ff !important;
|
| 76 |
+
color: #90cdf4 !important;
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/* ββ RESPONSIVE (OTP-specific) βββββββββββββββββββββββββββββ */
|
| 81 |
+
@media only screen and (max-width: 560px) {
|
| 82 |
+
.otp-digits {
|
| 83 |
+
font-size: 34px !important;
|
| 84 |
+
letter-spacing: 8px !important;
|
| 85 |
+
text-indent: 8px !important;
|
| 86 |
+
}
|
| 87 |
+
}
|
templates/email/css/_reset.css
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
PASSWORD RESET EMAIL β COMPONENT STYLES
|
| 3 |
+
Styles specific to the password-reset email template.
|
| 4 |
+
Included via Jinja2: {% include 'email/css/_reset.css' %}
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
/* ββ CTA Button ββββββββββββββββββββββββββββββββββββββββββββ */
|
| 8 |
+
.cta-wrap {
|
| 9 |
+
text-align: center;
|
| 10 |
+
margin: 4px 0 28px;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
.cta-button {
|
| 14 |
+
display: inline-block;
|
| 15 |
+
background: linear-gradient(135deg, #0078d4, #005fa3);
|
| 16 |
+
color: #ffffff !important;
|
| 17 |
+
font-size: 15px;
|
| 18 |
+
font-weight: 700;
|
| 19 |
+
padding: 15px 40px;
|
| 20 |
+
border-radius: 10px;
|
| 21 |
+
letter-spacing: 0.3px;
|
| 22 |
+
text-decoration: none;
|
| 23 |
+
box-shadow: 0 4px 16px rgba(0, 120, 212, 0.3);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/* ββ Fallback Link βββββββββββββββββββββββββββββββββββββββββ */
|
| 27 |
+
.cta-fallback {
|
| 28 |
+
font-size: 12px;
|
| 29 |
+
color: #a0aec0;
|
| 30 |
+
margin-top: 14px;
|
| 31 |
+
line-height: 1.7;
|
| 32 |
+
word-break: break-all;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.cta-fallback a {
|
| 36 |
+
color: #0078d4;
|
| 37 |
+
text-decoration: underline;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* ββ Warning Box βββββββββββββββββββββββββββββββββββββββββββ */
|
| 41 |
+
.warning-box {
|
| 42 |
+
background: #fff5f5;
|
| 43 |
+
border-left: 4px solid #e53e3e;
|
| 44 |
+
border-radius: 0 8px 8px 0;
|
| 45 |
+
padding: 14px 16px;
|
| 46 |
+
margin-bottom: 24px;
|
| 47 |
+
font-size: 13px;
|
| 48 |
+
color: #742a2a;
|
| 49 |
+
line-height: 1.6;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/* ββ Expiry Note βββββββββββββββββββββββββββββββββββββββββββ */
|
| 53 |
+
.expiry-note {
|
| 54 |
+
text-align: center;
|
| 55 |
+
font-size: 12px;
|
| 56 |
+
color: #718096;
|
| 57 |
+
margin-bottom: 24px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.expiry-note strong {
|
| 61 |
+
color: #e53e3e;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/* ββ DARK THEME (reset-specific overrides) βββββββββββββββββ */
|
| 65 |
+
@media (prefers-color-scheme: dark) {
|
| 66 |
+
.cta-button {
|
| 67 |
+
background: linear-gradient(135deg, #0091ea, #0078d4) !important;
|
| 68 |
+
box-shadow: 0 4px 20px rgba(0, 212, 255, 0.25) !important;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.cta-fallback { color: #4a5568 !important; }
|
| 72 |
+
.cta-fallback a { color: #00d4ff !important; }
|
| 73 |
+
|
| 74 |
+
.warning-box {
|
| 75 |
+
background: rgba(229, 62, 62, 0.1) !important;
|
| 76 |
+
border-left-color: #fc8181 !important;
|
| 77 |
+
color: #feb2b2 !important;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.expiry-note { color: #4a5568 !important; }
|
| 81 |
+
.expiry-note strong { color: #fc8181 !important; }
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* ββ RESPONSIVE (reset-specific) ββββββββββββββββββββββββββ */
|
| 85 |
+
@media only screen and (max-width: 560px) {
|
| 86 |
+
.cta-button {
|
| 87 |
+
padding: 14px 28px !important;
|
| 88 |
+
font-size: 14px !important;
|
| 89 |
+
}
|
| 90 |
+
}
|
templates/email/otp_email.html
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
| 7 |
+
<meta name="color-scheme" content="light dark" />
|
| 8 |
+
<meta name="supported-color-schemes" content="light dark" />
|
| 9 |
+
<title>{{ title }} β ICH Screening</title>
|
| 10 |
+
<style>
|
| 11 |
+
{# ββ Shared base: reset, layout, header, footer, dark theme ββ #}
|
| 12 |
+
{% include 'email/css/_base.css' %}
|
| 13 |
+
|
| 14 |
+
{# ββ OTP-specific: digit display, info block, dark overrides ββ #}
|
| 15 |
+
{% include 'email/css/_otp.css' %}
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div class="email-wrapper">
|
| 20 |
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
| 21 |
+
<tr>
|
| 22 |
+
<td align="center">
|
| 23 |
+
<div class="email-container">
|
| 24 |
+
|
| 25 |
+
<!-- βββββββββββ HEADER βββββββββββ -->
|
| 26 |
+
<div class="email-header">
|
| 27 |
+
|
| 28 |
+
<!-- Brand -->
|
| 29 |
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
| 30 |
+
<tr>
|
| 31 |
+
<td align="center">
|
| 32 |
+
<table role="presentation" cellpadding="0" cellspacing="0">
|
| 33 |
+
<tr>
|
| 34 |
+
<td style="padding-right:10px; vertical-align:middle;">
|
| 35 |
+
<div class="brand-icon">
|
| 36 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
| 37 |
+
stroke="#00d4ff" stroke-width="2.5"
|
| 38 |
+
stroke-linecap="round" stroke-linejoin="round">
|
| 39 |
+
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
| 40 |
+
</svg>
|
| 41 |
+
</div>
|
| 42 |
+
</td>
|
| 43 |
+
<td style="vertical-align:middle;">
|
| 44 |
+
<span class="brand-name">ICH Screening</span>
|
| 45 |
+
</td>
|
| 46 |
+
</tr>
|
| 47 |
+
</table>
|
| 48 |
+
</td>
|
| 49 |
+
</tr>
|
| 50 |
+
</table>
|
| 51 |
+
|
| 52 |
+
<!-- Brain / Neural SVG graphic -->
|
| 53 |
+
<div style="margin: 20px auto 0; text-align: center;">
|
| 54 |
+
<svg width="64" height="64" viewBox="0 0 64 64" fill="none"
|
| 55 |
+
xmlns="http://www.w3.org/2000/svg">
|
| 56 |
+
<circle cx="32" cy="32" r="30"
|
| 57 |
+
fill="rgba(0,212,255,0.08)"
|
| 58 |
+
stroke="rgba(0,212,255,0.4)"
|
| 59 |
+
stroke-width="1.5"/>
|
| 60 |
+
<path d="M22 28c0-5.5 4-10 10-10 3 0 5.5 1.2 7.2 3.2"
|
| 61 |
+
stroke="#00d4ff" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
| 62 |
+
<path d="M39 22c2.5 1.5 4 4.2 4 7 0 4-2.8 7.5-7 9"
|
| 63 |
+
stroke="#00d4ff" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
| 64 |
+
<path d="M36 38c-1.2.7-2.6 1-4 1-5.5 0-10-4-10-9 0-2.2.8-4.2 2-5.8"
|
| 65 |
+
stroke="#00d4ff" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
| 66 |
+
<path d="M24 32h4l2-5 3 10 2-5h5"
|
| 67 |
+
stroke="rgba(0,212,255,0.85)" stroke-width="1.6"
|
| 68 |
+
stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
| 69 |
+
<circle cx="32" cy="21" r="1.5" fill="rgba(0,212,255,0.6)"/>
|
| 70 |
+
<circle cx="22" cy="35" r="1.5" fill="rgba(0,212,255,0.6)"/>
|
| 71 |
+
<circle cx="40" cy="30" r="1.5" fill="rgba(0,212,255,0.6)"/>
|
| 72 |
+
</svg>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div class="header-title">{{ title }}</div>
|
| 76 |
+
<div class="header-subtitle">ICH Screening β Intracranial Hemorrhage Detection</div>
|
| 77 |
+
</div>
|
| 78 |
+
<!-- βββββββββββ END HEADER βββββββββββ -->
|
| 79 |
+
|
| 80 |
+
<!-- βββββββββββ BODY βββββββββββ -->
|
| 81 |
+
<div class="email-body">
|
| 82 |
+
|
| 83 |
+
<p class="greeting">
|
| 84 |
+
{% if recipient_name %}Hi <strong>{{ recipient_name }}</strong>,{% else %}Hi there,{% endif %}<br/><br/>
|
| 85 |
+
{% if purpose == 'verify_email' %}
|
| 86 |
+
Welcome to <strong>ICH Screening</strong>. You're one step away from accessing
|
| 87 |
+
our intracranial hemorrhage detection platform. Enter the verification code below
|
| 88 |
+
to confirm your email address and activate your account.
|
| 89 |
+
{% else %}
|
| 90 |
+
A verification code was requested for your <strong>ICH Screening</strong> account.
|
| 91 |
+
Enter the code below to continue. If this wasn't you, you can safely disregard
|
| 92 |
+
this message β your account remains secure.
|
| 93 |
+
{% endif %}
|
| 94 |
+
</p>
|
| 95 |
+
|
| 96 |
+
<!-- OTP Box -->
|
| 97 |
+
<div class="otp-label">Verification Code — Valid for 10 minutes</div>
|
| 98 |
+
<div class="otp-box">
|
| 99 |
+
<div class="otp-digits">
|
| 100 |
+
{%- set chars = otp_code | list -%}
|
| 101 |
+
{%- for i in range(chars | length) -%}
|
| 102 |
+
{%- if i == 3 %}<span class="otp-accent">{{ chars[i] }}</span>
|
| 103 |
+
{%- else %}{{ chars[i] }}{%- endif -%}
|
| 104 |
+
{%- endfor -%}
|
| 105 |
+
</div>
|
| 106 |
+
<div class="otp-expiry">
|
| 107 |
+
Do not share this code. It expires in <strong>10 minutes</strong>.
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<!-- Info notice -->
|
| 112 |
+
<div class="info-block">
|
| 113 |
+
<strong>Security reminder:</strong> ICH Screening will never ask you to share this
|
| 114 |
+
code over the phone, email, or chat. If anyone requests it, treat it as a phishing attempt.
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<hr class="divider"/>
|
| 118 |
+
|
| 119 |
+
<p class="security-notice">
|
| 120 |
+
Didn't sign up for ICH Screening? No worries β simply ignore this email.
|
| 121 |
+
Your code is useless without your login credentials, and your account will
|
| 122 |
+
remain inactive unless this code is entered.
|
| 123 |
+
</p>
|
| 124 |
+
|
| 125 |
+
</div>
|
| 126 |
+
<!-- βββββββββββ END BODY βββββββββββ -->
|
| 127 |
+
|
| 128 |
+
<!-- βββββββββββ FOOTER βββββββββββ -->
|
| 129 |
+
<div class="email-footer">
|
| 130 |
+
<div class="footer-brand">ICH Screening</div>
|
| 131 |
+
<div class="footer-text">
|
| 132 |
+
This message was sent automatically. Replies to this address are not monitored.<br/>
|
| 133 |
+
© {{ current_year }} ICH Screening — Intracranial Hemorrhage Detection Platform.
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
<!-- βββββββββββ END FOOTER βββββββββββ -->
|
| 137 |
+
|
| 138 |
+
</div><!-- /email-container -->
|
| 139 |
+
</td>
|
| 140 |
+
</tr>
|
| 141 |
+
</table>
|
| 142 |
+
</div>
|
| 143 |
+
</body>
|
| 144 |
+
</html>
|
templates/email/password_reset_email.html
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
| 7 |
+
<meta name="color-scheme" content="light dark" />
|
| 8 |
+
<meta name="supported-color-schemes" content="light dark" />
|
| 9 |
+
<title>Reset your Password β ICH Screening</title>
|
| 10 |
+
<style>
|
| 11 |
+
{# ββ Shared base: reset, layout, header, footer, dark theme ββ #}
|
| 12 |
+
{% include 'email/css/_base.css' %}
|
| 13 |
+
|
| 14 |
+
{# ββ Reset-specific: CTA button, warning box, expiry note ββ #}
|
| 15 |
+
{% include 'email/css/_reset.css' %}
|
| 16 |
+
</style>
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div class="email-wrapper">
|
| 20 |
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
| 21 |
+
<tr>
|
| 22 |
+
<td align="center">
|
| 23 |
+
<div class="email-container">
|
| 24 |
+
|
| 25 |
+
<!-- βββββββββββ HEADER βββββββββββ -->
|
| 26 |
+
<div class="email-header">
|
| 27 |
+
|
| 28 |
+
<!-- Brand -->
|
| 29 |
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
| 30 |
+
<tr>
|
| 31 |
+
<td align="center">
|
| 32 |
+
<table role="presentation" cellpadding="0" cellspacing="0">
|
| 33 |
+
<tr>
|
| 34 |
+
<td style="padding-right:10px; vertical-align:middle;">
|
| 35 |
+
<div class="brand-icon">
|
| 36 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
| 37 |
+
stroke="#00d4ff" stroke-width="2.5"
|
| 38 |
+
stroke-linecap="round" stroke-linejoin="round">
|
| 39 |
+
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
| 40 |
+
</svg>
|
| 41 |
+
</div>
|
| 42 |
+
</td>
|
| 43 |
+
<td style="vertical-align:middle;">
|
| 44 |
+
<span class="brand-name">ICH Screening</span>
|
| 45 |
+
</td>
|
| 46 |
+
</tr>
|
| 47 |
+
</table>
|
| 48 |
+
</td>
|
| 49 |
+
</tr>
|
| 50 |
+
</table>
|
| 51 |
+
|
| 52 |
+
<!-- Shield / Lock SVG graphic -->
|
| 53 |
+
<div style="margin: 20px auto 0; text-align: center;">
|
| 54 |
+
<svg width="68" height="68" viewBox="0 0 68 68" fill="none"
|
| 55 |
+
xmlns="http://www.w3.org/2000/svg">
|
| 56 |
+
<circle cx="34" cy="34" r="32"
|
| 57 |
+
fill="rgba(0,212,255,0.07)"
|
| 58 |
+
stroke="rgba(0,212,255,0.35)"
|
| 59 |
+
stroke-width="1.5"/>
|
| 60 |
+
<path d="M34 16 L48 22 L48 34 C48 42.5 41.5 49.5 34 52 C26.5 49.5 20 42.5 20 34 L20 22 Z"
|
| 61 |
+
fill="rgba(0,212,255,0.1)"
|
| 62 |
+
stroke="#00d4ff" stroke-width="1.8" stroke-linejoin="round"/>
|
| 63 |
+
<rect x="27" y="33" width="14" height="10" rx="2.5"
|
| 64 |
+
fill="rgba(0,212,255,0.25)" stroke="#00d4ff" stroke-width="1.5"/>
|
| 65 |
+
<path d="M29 33 L29 29.5 C29 26.5 39 26.5 39 29.5 L39 33"
|
| 66 |
+
stroke="#00d4ff" stroke-width="1.5" stroke-linecap="round" fill="none"/>
|
| 67 |
+
<circle cx="34" cy="37.5" r="1.5" fill="#00d4ff"/>
|
| 68 |
+
<path d="M34 39 L34 41" stroke="#00d4ff" stroke-width="1.5" stroke-linecap="round"/>
|
| 69 |
+
</svg>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div class="header-title">Let's get you back in</div>
|
| 73 |
+
<div class="header-subtitle">ICH Screening β Intracranial Hemorrhage Detection</div>
|
| 74 |
+
</div>
|
| 75 |
+
<!-- βββββββββββ END HEADER βββββββββββ -->
|
| 76 |
+
|
| 77 |
+
<!-- βββββββββββ BODY βββββββββββ -->
|
| 78 |
+
<div class="email-body">
|
| 79 |
+
|
| 80 |
+
<p class="greeting">
|
| 81 |
+
{% if recipient_name %}Hi <strong>{{ recipient_name }}</strong>,{% else %}Hi there,{% endif %}<br/><br/>
|
| 82 |
+
We received a request to reset the password for your <strong>ICH Screening</strong> account.
|
| 83 |
+
Use the button below to choose a new password β it only takes a moment.
|
| 84 |
+
</p>
|
| 85 |
+
|
| 86 |
+
<!-- CTA Button -->
|
| 87 |
+
<div class="cta-wrap">
|
| 88 |
+
<a href="{{ reset_link }}" class="cta-button" id="reset-password-btn">
|
| 89 |
+
Reset My Password
|
| 90 |
+
</a>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- Expiry notice -->
|
| 94 |
+
<div class="expiry-note">
|
| 95 |
+
This reset link is single-use and expires in <strong>30 minutes</strong>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<!-- Fallback link text -->
|
| 99 |
+
<div class="cta-fallback" style="text-align: center;">
|
| 100 |
+
Button not working? Paste this link directly into your browser's address bar:<br/>
|
| 101 |
+
<a href="{{ reset_link }}">{{ reset_link }}</a>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<hr class="divider"/>
|
| 105 |
+
|
| 106 |
+
<!-- Warning -->
|
| 107 |
+
<div class="warning-box">
|
| 108 |
+
<strong>Didn't request this?</strong> You can ignore this email β your password
|
| 109 |
+
has not been changed and your account remains intact. If you keep receiving
|
| 110 |
+
unexpected reset emails, consider reviewing your account security.
|
| 111 |
+
</div>
|
| 112 |
+
|
| 113 |
+
<p class="security-notice">
|
| 114 |
+
For your protection, this link expires automatically and can only be used once.
|
| 115 |
+
Need a new link? Head back to the
|
| 116 |
+
<a href="#" style="color: #718096;">Forgot Password</a> page and try again.
|
| 117 |
+
</p>
|
| 118 |
+
|
| 119 |
+
</div>
|
| 120 |
+
<!-- βββββββββββ END BODY βββββββββββ -->
|
| 121 |
+
|
| 122 |
+
<!-- βββββββββββ FOOTER βββββββββββ -->
|
| 123 |
+
<div class="email-footer">
|
| 124 |
+
<div class="footer-brand">ICH Screening</div>
|
| 125 |
+
<div class="footer-text">
|
| 126 |
+
This message was sent automatically. Replies to this address are not monitored.<br/>
|
| 127 |
+
© {{ current_year }} ICH Screening — Intracranial Hemorrhage Detection Platform.
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
<!-- βββββββββββ END FOOTER βββββββββββ -->
|
| 131 |
+
|
| 132 |
+
</div><!-- /email-container -->
|
| 133 |
+
</td>
|
| 134 |
+
</tr>
|
| 135 |
+
</table>
|
| 136 |
+
</div>
|
| 137 |
+
</body>
|
| 138 |
+
</html>
|