Harshit Ghosh commited on
Commit ·
8e35842
1
Parent(s): 97d1d40
Fix OTP: move to DB storage, cookie-free token URL params, fix notice banners
Browse files- auth_routes.py +97 -106
- models.py +27 -0
- templates/auth/verify_otp.html +28 -7
auth_routes.py
CHANGED
|
@@ -24,13 +24,11 @@ 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, now_ist
|
| 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:
|
|
@@ -50,73 +48,64 @@ def _generate_otp() -> str:
|
|
| 50 |
return f"{secrets.randbelow(1_000_000):06d}"
|
| 51 |
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
session.modified = True
|
| 76 |
|
| 77 |
|
| 78 |
-
def
|
| 79 |
-
|
| 80 |
-
if
|
| 81 |
-
return
|
| 82 |
-
|
| 83 |
-
return "".join(digits)
|
| 84 |
|
| 85 |
|
| 86 |
-
def _validate_otp(submitted_code: str, expected_purpose: str
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 95 |
-
|
| 96 |
-
_clear_otp()
|
| 97 |
-
return False, "OTP is invalid. Please request a new code.", None
|
| 98 |
-
try:
|
| 99 |
-
expires_at = datetime.fromisoformat(expires_raw)
|
| 100 |
-
except Exception:
|
| 101 |
-
_clear_otp()
|
| 102 |
-
return False, "OTP is invalid. Please request a new code.", None
|
| 103 |
-
|
| 104 |
-
if now_ist() > expires_at:
|
| 105 |
-
_clear_otp()
|
| 106 |
return False, "OTP expired. Please request a new code.", None
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
_clear_otp()
|
| 111 |
return False, "Too many failed attempts. Please request a new code.", None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
if _hash_otp(submitted_code) != payload.get("otp_hash"):
|
| 114 |
-
payload["attempts"] = attempts + 1
|
| 115 |
-
session[OTP_SESSION_KEY] = payload
|
| 116 |
-
session.modified = True
|
| 117 |
-
return False, "Invalid OTP code.", None
|
| 118 |
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def _otp_body(code: str, purpose: str) -> str:
|
|
@@ -245,7 +234,7 @@ def register():
|
|
| 245 |
db.session.add(user)
|
| 246 |
db.session.commit()
|
| 247 |
|
| 248 |
-
otp_code = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
|
| 249 |
sent = _send_email(
|
| 250 |
user.email,
|
| 251 |
"Your ICH Screening verification code",
|
|
@@ -255,12 +244,9 @@ def register():
|
|
| 255 |
logger.info("DEV OTP for %s: %s", user.email, otp_code)
|
| 256 |
|
| 257 |
log_audit('user_registered', user_id=user.id, status='success')
|
| 258 |
-
if sent
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
flash('Registration successful, but email delivery failed. Configure SMTP and resend OTP.', 'warning')
|
| 262 |
-
|
| 263 |
-
return redirect(url_for('auth.verify_otp', purpose='verify_email', email=user.email))
|
| 264 |
|
| 265 |
except Exception as e:
|
| 266 |
db.session.rollback()
|
|
@@ -298,7 +284,7 @@ def login():
|
|
| 298 |
return render_template('auth/login.html'), 401
|
| 299 |
|
| 300 |
if not user.is_active:
|
| 301 |
-
otp_code = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
|
| 302 |
sent = _send_email(
|
| 303 |
user.email,
|
| 304 |
"Your ICH Screening verification code",
|
|
@@ -307,11 +293,9 @@ def login():
|
|
| 307 |
if _auth_email_debug_enabled():
|
| 308 |
logger.info("DEV OTP resend/login for %s: %s", user.email, otp_code)
|
| 309 |
log_audit('login_failed', user_id=user.id, status='failure', details='Email not verified')
|
| 310 |
-
if sent
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
flash('Please verify your email. OTP generation worked but SMTP is not configured.', 'warning')
|
| 314 |
-
return redirect(url_for('auth.verify_otp', purpose='verify_email', email=user.email))
|
| 315 |
|
| 316 |
if not user.check_password(password):
|
| 317 |
logger.warning("Failed login attempt for identifier: %s", identifier)
|
|
@@ -384,60 +368,67 @@ def forgot_password():
|
|
| 384 |
|
| 385 |
@auth_bp.route('/verify-otp', methods=['GET', 'POST'])
|
| 386 |
def verify_otp():
|
| 387 |
-
"""Verify one-time password
|
| 388 |
-
purpose
|
| 389 |
-
|
| 390 |
-
email
|
|
|
|
| 391 |
|
| 392 |
if request.method == 'POST':
|
| 393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
if len(submitted) != 6 or not submitted.isdigit():
|
| 395 |
-
|
| 396 |
-
|
| 397 |
|
| 398 |
-
ok, msg,
|
| 399 |
if not ok:
|
| 400 |
-
|
| 401 |
-
return
|
|
|
|
| 402 |
|
| 403 |
-
|
| 404 |
-
user = User.query.get(int(user_id)) if user_id else None
|
| 405 |
if not user:
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
return redirect(url_for('auth.register'))
|
| 409 |
|
| 410 |
user.is_active = True
|
| 411 |
-
|
| 412 |
-
_clear_otp()
|
| 413 |
log_audit('email_verified', user_id=user.id, status='success')
|
| 414 |
flash('Email verified. You can now sign in.', 'success')
|
| 415 |
return redirect(url_for('auth.login'))
|
| 416 |
|
| 417 |
-
return render_template('auth/verify_otp.html', email=email, purpose=purpose
|
|
|
|
| 418 |
|
| 419 |
|
| 420 |
@auth_bp.route('/resend-otp', methods=['POST'])
|
| 421 |
def resend_otp():
|
| 422 |
-
"""Resend OTP
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
sent = _send_email(email, "Your ICH Screening verification code", _otp_body(new_code, purpose))
|
| 433 |
if _auth_email_debug_enabled():
|
| 434 |
logger.info("DEV OTP resend for %s: %s", email, new_code)
|
| 435 |
|
| 436 |
-
if sent
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
flash('Failed to send OTP email. Please configure SMTP settings.', 'error')
|
| 440 |
-
return redirect(url_for('auth.verify_otp', purpose=purpose, email=email))
|
| 441 |
|
| 442 |
|
| 443 |
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
|
|
|
| 24 |
from sqlalchemy import func, or_
|
| 25 |
|
| 26 |
from auth_utils import log_audit, validate_email, validate_password, validate_username
|
| 27 |
+
from models import PendingOtp, User, db, now_ist
|
| 28 |
|
| 29 |
logger = logging.getLogger(__name__)
|
| 30 |
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
|
| 31 |
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def _parse_bool(raw: str | None, default: bool = False) -> bool:
|
| 34 |
if raw is None:
|
|
|
|
| 48 |
return f"{secrets.randbelow(1_000_000):06d}"
|
| 49 |
|
| 50 |
|
| 51 |
+
# ── DB-based OTP helpers (session-cookie-free) ────────────────────────────────
|
| 52 |
+
|
| 53 |
+
def _store_otp(email: str, purpose: str, user_id: int | None = None,
|
| 54 |
+
pending_value: str | None = None) -> tuple[str, str]:
|
| 55 |
+
"""Generate a new OTP, persist it in the DB, return (code, token)."""
|
| 56 |
+
# Delete any existing pending OTPs for this email+purpose to keep the table clean
|
| 57 |
+
PendingOtp.query.filter_by(email=email, purpose=purpose).delete()
|
| 58 |
+
code = _generate_otp()
|
| 59 |
+
token = secrets.token_urlsafe(32)
|
| 60 |
+
row = PendingOtp(
|
| 61 |
+
token = token,
|
| 62 |
+
email = email,
|
| 63 |
+
purpose = purpose,
|
| 64 |
+
otp_hash = _hash_otp(code),
|
| 65 |
+
expires_at = now_ist() + timedelta(minutes=10),
|
| 66 |
+
attempts = 0,
|
| 67 |
+
user_id = user_id,
|
| 68 |
+
pending_value = pending_value,
|
| 69 |
+
)
|
| 70 |
+
db.session.add(row)
|
| 71 |
+
db.session.commit()
|
| 72 |
+
return code, token
|
|
|
|
| 73 |
|
| 74 |
|
| 75 |
+
def _otp_row_from_token(token: str | None) -> PendingOtp | None:
|
| 76 |
+
"""Look up a PendingOtp row by its opaque token."""
|
| 77 |
+
if not token:
|
| 78 |
+
return None
|
| 79 |
+
return PendingOtp.query.filter_by(token=token).first()
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
+
def _validate_otp(submitted_code: str, expected_purpose: str,
|
| 83 |
+
token: str | None) -> tuple[bool, str, PendingOtp | None]:
|
| 84 |
+
"""Validate a submitted OTP code. Returns (ok, message, row)."""
|
| 85 |
+
row = _otp_row_from_token(token)
|
| 86 |
+
if not row:
|
| 87 |
return False, "OTP session is missing or expired. Please request a new code.", None
|
| 88 |
+
if row.purpose != expected_purpose:
|
|
|
|
| 89 |
return False, "OTP purpose mismatch. Please request a new code.", None
|
| 90 |
+
if row.is_expired():
|
| 91 |
+
db.session.delete(row)
|
| 92 |
+
db.session.commit()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
return False, "OTP expired. Please request a new code.", None
|
| 94 |
+
if row.attempts >= 5:
|
| 95 |
+
db.session.delete(row)
|
| 96 |
+
db.session.commit()
|
|
|
|
| 97 |
return False, "Too many failed attempts. Please request a new code.", None
|
| 98 |
+
if _hash_otp(submitted_code) != row.otp_hash:
|
| 99 |
+
row.attempts += 1
|
| 100 |
+
db.session.commit()
|
| 101 |
+
remaining = 5 - row.attempts
|
| 102 |
+
return False, f"Invalid OTP code. {remaining} attempt(s) remaining.", None
|
| 103 |
+
return True, "", row
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
def _clear_otp_row(row: PendingOtp) -> None:
|
| 107 |
+
db.session.delete(row)
|
| 108 |
+
db.session.commit()
|
| 109 |
|
| 110 |
|
| 111 |
def _otp_body(code: str, purpose: str) -> str:
|
|
|
|
| 234 |
db.session.add(user)
|
| 235 |
db.session.commit()
|
| 236 |
|
| 237 |
+
otp_code, otp_token = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
|
| 238 |
sent = _send_email(
|
| 239 |
user.email,
|
| 240 |
"Your ICH Screening verification code",
|
|
|
|
| 244 |
logger.info("DEV OTP for %s: %s", user.email, otp_code)
|
| 245 |
|
| 246 |
log_audit('user_registered', user_id=user.id, status='success')
|
| 247 |
+
notice = 'otp_sent' if sent else 'otp_email_failed'
|
| 248 |
+
return redirect(url_for('auth.verify_otp', purpose='verify_email',
|
| 249 |
+
email=user.email, otp_token=otp_token, notice=notice))
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
except Exception as e:
|
| 252 |
db.session.rollback()
|
|
|
|
| 284 |
return render_template('auth/login.html'), 401
|
| 285 |
|
| 286 |
if not user.is_active:
|
| 287 |
+
otp_code, otp_token = _store_otp(email=user.email, purpose="verify_email", user_id=user.id)
|
| 288 |
sent = _send_email(
|
| 289 |
user.email,
|
| 290 |
"Your ICH Screening verification code",
|
|
|
|
| 293 |
if _auth_email_debug_enabled():
|
| 294 |
logger.info("DEV OTP resend/login for %s: %s", user.email, otp_code)
|
| 295 |
log_audit('login_failed', user_id=user.id, status='failure', details='Email not verified')
|
| 296 |
+
notice = 'otp_resent' if sent else 'otp_email_failed'
|
| 297 |
+
return redirect(url_for('auth.verify_otp', purpose='verify_email',
|
| 298 |
+
email=user.email, otp_token=otp_token, notice=notice))
|
|
|
|
|
|
|
| 299 |
|
| 300 |
if not user.check_password(password):
|
| 301 |
logger.warning("Failed login attempt for identifier: %s", identifier)
|
|
|
|
| 368 |
|
| 369 |
@auth_bp.route('/verify-otp', methods=['GET', 'POST'])
|
| 370 |
def verify_otp():
|
| 371 |
+
"""Verify one-time password — uses DB row via otp_token URL param (cookie-free)."""
|
| 372 |
+
purpose = request.args.get('purpose', 'verify_email')
|
| 373 |
+
otp_token = request.args.get('otp_token') or request.form.get('otp_token', '')
|
| 374 |
+
email = request.args.get('email', '')
|
| 375 |
+
notice = request.args.get('notice', '')
|
| 376 |
|
| 377 |
if request.method == 'POST':
|
| 378 |
+
# Reconstruct digits → 6-char string
|
| 379 |
+
direct = request.form.get('otp', '').strip()
|
| 380 |
+
if not direct:
|
| 381 |
+
direct = ''.join(request.form.get(f'd{i}', '').strip() for i in range(1, 7))
|
| 382 |
+
submitted = direct
|
| 383 |
+
|
| 384 |
if len(submitted) != 6 or not submitted.isdigit():
|
| 385 |
+
return redirect(url_for('auth.verify_otp', purpose=purpose, email=email,
|
| 386 |
+
otp_token=otp_token, notice='invalid_digits'))
|
| 387 |
|
| 388 |
+
ok, msg, row = _validate_otp(submitted, purpose, otp_token)
|
| 389 |
if not ok:
|
| 390 |
+
logger.warning("OTP validation failed for %s: %s", email, msg)
|
| 391 |
+
return redirect(url_for('auth.verify_otp', purpose=purpose, email=email,
|
| 392 |
+
otp_token=otp_token, notice='invalid_code'))
|
| 393 |
|
| 394 |
+
user = User.query.get(int(row.user_id)) if row.user_id else None
|
|
|
|
| 395 |
if not user:
|
| 396 |
+
_clear_otp_row(row)
|
| 397 |
+
return redirect(url_for('auth.register') + '?notice=session_invalid')
|
|
|
|
| 398 |
|
| 399 |
user.is_active = True
|
| 400 |
+
_clear_otp_row(row)
|
|
|
|
| 401 |
log_audit('email_verified', user_id=user.id, status='success')
|
| 402 |
flash('Email verified. You can now sign in.', 'success')
|
| 403 |
return redirect(url_for('auth.login'))
|
| 404 |
|
| 405 |
+
return render_template('auth/verify_otp.html', email=email, purpose=purpose,
|
| 406 |
+
otp_token=otp_token, notice=notice)
|
| 407 |
|
| 408 |
|
| 409 |
@auth_bp.route('/resend-otp', methods=['POST'])
|
| 410 |
def resend_otp():
|
| 411 |
+
"""Resend OTP — recovers context from the otp_token in the form, not the session."""
|
| 412 |
+
old_token = request.form.get('otp_token', '')
|
| 413 |
+
email = request.form.get('email', '')
|
| 414 |
+
purpose = request.form.get('purpose', 'verify_email')
|
| 415 |
+
|
| 416 |
+
# Look up the old row to get user_id
|
| 417 |
+
old_row = _otp_row_from_token(old_token)
|
| 418 |
+
user_id = old_row.user_id if old_row else None
|
| 419 |
+
|
| 420 |
+
# If we have no row and no email, we can't recover
|
| 421 |
+
if not email:
|
| 422 |
+
return redirect(url_for('auth.register') + '?notice=session_invalid')
|
| 423 |
+
|
| 424 |
+
new_code, new_token = _store_otp(email=email, purpose=purpose, user_id=user_id)
|
| 425 |
sent = _send_email(email, "Your ICH Screening verification code", _otp_body(new_code, purpose))
|
| 426 |
if _auth_email_debug_enabled():
|
| 427 |
logger.info("DEV OTP resend for %s: %s", email, new_code)
|
| 428 |
|
| 429 |
+
notice = 'otp_resent' if sent else 'otp_email_failed'
|
| 430 |
+
return redirect(url_for('auth.verify_otp', purpose=purpose, email=email,
|
| 431 |
+
otp_token=new_token, notice=notice))
|
|
|
|
|
|
|
| 432 |
|
| 433 |
|
| 434 |
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
|
models.py
CHANGED
|
@@ -28,6 +28,9 @@ class User(UserMixin, db.Model):
|
|
| 28 |
created_at = db.Column(db.DateTime, default=now_ist, nullable=False)
|
| 29 |
updated_at = db.Column(db.DateTime, default=now_ist, onupdate=now_ist)
|
| 30 |
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
# Relationships
|
| 33 |
screening_uploads = db.relationship('ScreeningUpload', backref='user', lazy=True, cascade='all, delete-orphan')
|
|
@@ -119,3 +122,27 @@ class AuditLog(db.Model):
|
|
| 119 |
|
| 120 |
def __repr__(self):
|
| 121 |
return f'<AuditLog {self.action} - user {self.user_id} - {self.timestamp}>'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
created_at = db.Column(db.DateTime, default=now_ist, nullable=False)
|
| 29 |
updated_at = db.Column(db.DateTime, default=now_ist, onupdate=now_ist)
|
| 30 |
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
| 31 |
+
# Avatar (Cloudinary)
|
| 32 |
+
avatar_url = db.Column(db.String(500), nullable=True)
|
| 33 |
+
avatar_public_id = db.Column(db.String(255), nullable=True)
|
| 34 |
|
| 35 |
# Relationships
|
| 36 |
screening_uploads = db.relationship('ScreeningUpload', backref='user', lazy=True, cascade='all, delete-orphan')
|
|
|
|
| 122 |
|
| 123 |
def __repr__(self):
|
| 124 |
return f'<AuditLog {self.action} - user {self.user_id} - {self.timestamp}>'
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class PendingOtp(db.Model):
|
| 128 |
+
"""Server-side OTP storage — avoids relying on session cookies (broken in cross-origin iframes)."""
|
| 129 |
+
__tablename__ = 'pending_otps'
|
| 130 |
+
|
| 131 |
+
id = db.Column(db.Integer, primary_key=True)
|
| 132 |
+
# Opaque lookup token sent to the browser as a URL param (never the raw code)
|
| 133 |
+
token = db.Column(db.String(64), unique=True, nullable=False, index=True)
|
| 134 |
+
email = db.Column(db.String(120), nullable=False, index=True)
|
| 135 |
+
purpose = db.Column(db.String(50), nullable=False) # verify_email | change_username | change_email
|
| 136 |
+
otp_hash = db.Column(db.String(64), nullable=False) # SHA-256 of the 6-digit code
|
| 137 |
+
expires_at = db.Column(db.DateTime, nullable=False)
|
| 138 |
+
attempts = db.Column(db.Integer, default=0, nullable=False)
|
| 139 |
+
# Optional: store pending new value (e.g. new username / new email)
|
| 140 |
+
pending_value = db.Column(db.String(255), nullable=True)
|
| 141 |
+
# Optional FK — may be NULL for pre-registration flows
|
| 142 |
+
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=True, index=True)
|
| 143 |
+
|
| 144 |
+
def is_expired(self) -> bool:
|
| 145 |
+
return now_ist() > self.expires_at
|
| 146 |
+
|
| 147 |
+
def __repr__(self):
|
| 148 |
+
return f'<PendingOtp {self.purpose} for {self.email} expires {self.expires_at}>'
|
templates/auth/verify_otp.html
CHANGED
|
@@ -42,6 +42,21 @@
|
|
| 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">
|
|
@@ -52,21 +67,27 @@
|
|
| 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 |
|
|
|
|
| 42 |
<p>Sent to <strong>{{ email or 'your registered email' }}</strong></p>
|
| 43 |
</div>
|
| 44 |
|
| 45 |
+
{# --- Notice banners rendered from URL param (cookie-free) --- #}
|
| 46 |
+
{% set notice = request.args.get('notice', '') %}
|
| 47 |
+
{% if notice == 'otp_sent' %}
|
| 48 |
+
<div class="alert alert-success">✅ Registration successful. A verification code was sent to your email.</div>
|
| 49 |
+
{% elif notice == 'otp_resent' %}
|
| 50 |
+
<div class="alert alert-success">✅ A fresh verification code was sent to your email.</div>
|
| 51 |
+
{% elif notice == 'otp_email_failed' %}
|
| 52 |
+
<div class="alert alert-info">⚠️ Account created, but the email could not be sent. Please use "Resend code" below.</div>
|
| 53 |
+
{% elif notice == 'invalid_digits' %}
|
| 54 |
+
<div class="alert alert-error">Please enter the full 6-digit code.</div>
|
| 55 |
+
{% elif notice == 'invalid_code' %}
|
| 56 |
+
<div class="alert alert-error">Invalid or expired code. Please try again or request a new one.</div>
|
| 57 |
+
{% endif %}
|
| 58 |
+
|
| 59 |
+
{# --- Legacy flash messages (still shown for local dev) --- #}
|
| 60 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 61 |
{% if messages %}
|
| 62 |
<div class="auth-alerts">
|
|
|
|
| 67 |
{% endif %}
|
| 68 |
{% endwith %}
|
| 69 |
|
| 70 |
+
<form method="POST" class="auth-form" id="otpForm"
|
| 71 |
+
action="{{ url_for('auth.verify_otp', purpose=purpose, email=email, otp_token=otp_token) }}">
|
| 72 |
+
{# Carry the token in a hidden input so it survives the POST #}
|
| 73 |
+
<input type="hidden" name="otp_token" value="{{ otp_token }}"/>
|
| 74 |
<input type="hidden" name="otp" id="otpCombined"/>
|
| 75 |
<div class="otp-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;">
|
| 76 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d1" class="otp-digit" autocomplete="off" required/>
|
| 77 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d2" class="otp-digit" autocomplete="off" required/>
|
| 78 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d3" class="otp-digit" autocomplete="off" required/>
|
| 79 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d4" class="otp-digit" autocomplete="off" required/>
|
| 80 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d5" class="otp-digit" autocomplete="off" required/>
|
| 81 |
+
<input type="text" inputmode="numeric" maxlength="1" name="d6" class="otp-digit" autocomplete="off" required/>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
<button type="submit" class="btn-auth-submit" style="margin-top:16px;">Verify & Activate Account</button>
|
| 85 |
</form>
|
| 86 |
|
| 87 |
<form method="POST" action="{{ url_for('auth.resend_otp') }}" style="margin-top:12px;">
|
| 88 |
+
<input type="hidden" name="otp_token" value="{{ otp_token }}"/>
|
| 89 |
+
<input type="hidden" name="email" value="{{ email }}"/>
|
| 90 |
+
<input type="hidden" name="purpose" value="{{ purpose }}"/>
|
| 91 |
<button type="submit" class="btn-auth-submit" style="background:#0f1b31;border:1px solid #2a3f68;">Didn't receive it? Resend code</button>
|
| 92 |
</form>
|
| 93 |
|