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 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=Get from Neon dashboard - format: postgresql://username:password@host:port/database
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
- Authentication routes: login, register, logout
3
- """
4
  import logging
5
- from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
6
- from flask_login import login_user, logout_user, current_user
7
- from models import db, User
8
- from auth_utils import (
9
- validate_username, validate_password, validate_email, log_audit
 
 
 
 
 
 
 
 
 
 
 
 
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
- flash('Registration successful! Please log in.', 'success')
73
- return redirect(url_for('auth.login'))
 
 
 
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
- username = request.form.get('username', '').strip()
 
93
  password = request.form.get('password', '')
94
- remember = request.form.get('remember', False)
95
-
96
- user = User.query.filter_by(username=username).first()
 
 
 
 
 
97
 
98
  if not user:
99
- logger.warning(f"Login attempt with non-existent username: {username}")
100
- log_audit('login_failed', status='failure', details=f'User not found: {username}')
101
  flash('Invalid username or password', 'error')
102
  return render_template('auth/login.html'), 401
103
 
104
  if not user.is_active:
105
- log_audit('login_failed', user_id=user.id, status='failure', details='Account inactive')
106
- flash('Your account has been deactivated', 'error')
107
- return render_template('auth/login.html'), 403
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  if not user.check_password(password):
110
- logger.warning(f"Failed login attempt for user: {username}")
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 β€” shows a polished form; no email is sent (SMTP not configured)."""
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
- # We always return the same response to prevent user enumeration.
151
- logger.info(f"Password reset requested for email: {email}")
152
- log_audit('password_reset_requested', status='info', details=f'Email: {email}')
153
- # Redirect with ?sent=1 so the template can show the success state
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>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,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 email address is registered, you'll receive a password reset link shortly. Check your spam folder if you don't see it.
 
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
- Remember your password? <a href="{{ url_for('auth.login') }}">Sign in</a>
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 and automated triage reporting.</p>
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>Sign in to your ICH Screening account</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="username">Username</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="username" name="username" required autofocus
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
- Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a>
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 stay private
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 & 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
- Results in seconds
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>Fill in your details to get started</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+ chars, upper, lower, digit" 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,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 &amp; 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 &amp; 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 &mdash; 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
+ &copy; {{ current_year }} ICH Screening &mdash; 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
+ &copy; {{ current_year }} ICH Screening &mdash; 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>