Update app.py
Browse files
app.py
CHANGED
|
@@ -1,22 +1,45 @@
|
|
| 1 |
import os
|
| 2 |
import json
|
| 3 |
import secrets
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
import streamlit as st
|
| 7 |
from sqlalchemy import (
|
| 8 |
-
create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text
|
| 9 |
)
|
| 10 |
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
| 11 |
from passlib.context import CryptContext
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# ----------------------------
|
| 14 |
-
# Config: DB path (
|
| 15 |
# ----------------------------
|
| 16 |
# Hugging Face Spaces persistent storage (if enabled) is typically mounted at /data
|
| 17 |
-
# If /data is not available, it will use local file (may reset on rebuild).
|
| 18 |
DB_DIR = "/data" if os.path.isdir("/data") else "."
|
| 19 |
-
DB_PATH = os.path.join(DB_DIR, "
|
| 20 |
|
| 21 |
engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
|
| 22 |
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
|
@@ -36,6 +59,15 @@ class User(Base):
|
|
| 36 |
password_hash = Column(String, nullable=False)
|
| 37 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
accounts = relationship("BankAccount", back_populates="owner", cascade="all, delete-orphan")
|
| 40 |
payees = relationship("Payee", back_populates="owner", cascade="all, delete-orphan")
|
| 41 |
|
|
@@ -82,25 +114,64 @@ class Transaction(Base):
|
|
| 82 |
Base.metadata.create_all(bind=engine)
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
# ----------------------------
|
| 86 |
# Helpers
|
| 87 |
# ----------------------------
|
|
|
|
|
|
|
|
|
|
| 88 |
def hash_password(pw: str) -> str:
|
| 89 |
return pwd_context.hash(pw)
|
| 90 |
|
| 91 |
def verify_password(pw: str, hashed: str) -> bool:
|
| 92 |
return pwd_context.verify(pw, hashed)
|
| 93 |
|
| 94 |
-
def
|
| 95 |
-
return
|
| 96 |
|
| 97 |
-
def
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
return acct
|
| 104 |
|
| 105 |
def money(x: float) -> str:
|
| 106 |
return f"${x:,.2f}"
|
|
@@ -111,25 +182,234 @@ def require_login():
|
|
| 111 |
st.stop()
|
| 112 |
|
| 113 |
def get_user(session):
|
| 114 |
-
return session.
|
| 115 |
|
| 116 |
def get_user_accounts(session, user_id: int):
|
| 117 |
return session.query(BankAccount).filter_by(owner_id=user_id).order_by(BankAccount.created_at.asc()).all()
|
| 118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
# ----------------------------
|
| 121 |
# Core banking operations
|
| 122 |
# ----------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
def open_account(full_name: str, email: str, password: str, nickname: str = "Checking"):
|
| 124 |
session = db_session()
|
| 125 |
try:
|
| 126 |
-
|
|
|
|
| 127 |
return False, "Email already registered."
|
| 128 |
|
|
|
|
|
|
|
|
|
|
| 129 |
user = User(
|
| 130 |
full_name=full_name.strip(),
|
| 131 |
-
email=
|
| 132 |
password_hash=hash_password(password),
|
|
|
|
| 133 |
)
|
| 134 |
session.add(user)
|
| 135 |
session.flush()
|
|
@@ -142,8 +422,42 @@ def open_account(full_name: str, email: str, password: str, nickname: str = "Che
|
|
| 142 |
balance=0.0,
|
| 143 |
)
|
| 144 |
session.add(acct)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
session.commit()
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
except Exception as e:
|
| 148 |
session.rollback()
|
| 149 |
return False, f"Error: {e}"
|
|
@@ -157,6 +471,9 @@ def login(email: str, password: str):
|
|
| 157 |
if not user or not verify_password(password, user.password_hash):
|
| 158 |
return False, "Invalid email or password."
|
| 159 |
|
|
|
|
|
|
|
|
|
|
| 160 |
st.session_state["user_id"] = user.id
|
| 161 |
st.session_state["user_name"] = user.full_name
|
| 162 |
return True, f"Welcome, {user.full_name}!"
|
|
@@ -174,7 +491,9 @@ def deposit(user_id: int, account_number: str, amount: float, note: str = ""):
|
|
| 174 |
|
| 175 |
session = db_session()
|
| 176 |
try:
|
| 177 |
-
acct = session.query(BankAccount).filter_by(
|
|
|
|
|
|
|
| 178 |
if not acct:
|
| 179 |
return False, "Account not found."
|
| 180 |
|
|
@@ -204,11 +523,15 @@ def transfer(user_id: int, from_acct_num: str, to_acct_num: str, amount: float,
|
|
| 204 |
|
| 205 |
session = db_session()
|
| 206 |
try:
|
| 207 |
-
from_acct = session.query(BankAccount).filter_by(
|
|
|
|
|
|
|
| 208 |
if not from_acct:
|
| 209 |
return False, "Source account not found."
|
| 210 |
|
| 211 |
-
to_acct = session.query(BankAccount).filter_by(
|
|
|
|
|
|
|
| 212 |
if not to_acct:
|
| 213 |
return False, "Destination account number not found."
|
| 214 |
|
|
@@ -255,7 +578,9 @@ def pay_bill(user_id: int, from_acct_num: str, payee_id: int, amount: float, not
|
|
| 255 |
|
| 256 |
session = db_session()
|
| 257 |
try:
|
| 258 |
-
acct = session.query(BankAccount).filter_by(
|
|
|
|
|
|
|
| 259 |
if not acct:
|
| 260 |
return False, "Account not found."
|
| 261 |
|
|
@@ -288,7 +613,7 @@ def pay_bill(user_id: int, from_acct_num: str, payee_id: int, amount: float, not
|
|
| 288 |
# ----------------------------
|
| 289 |
# UI
|
| 290 |
# ----------------------------
|
| 291 |
-
st.set_page_config(page_title=
|
| 292 |
|
| 293 |
st.markdown(
|
| 294 |
"""
|
|
@@ -297,6 +622,7 @@ st.markdown(
|
|
| 297 |
.subtle { color: #6b7280; margin-top: 0; }
|
| 298 |
.card { padding: 1rem; border-radius: 14px; border: 1px solid #e5e7eb; background: #fff; }
|
| 299 |
.small { font-size: 0.9rem; color: #6b7280; }
|
|
|
|
| 300 |
</style>
|
| 301 |
""",
|
| 302 |
unsafe_allow_html=True,
|
|
@@ -305,7 +631,8 @@ st.markdown(
|
|
| 305 |
left, right = st.columns([3, 2], gap="large")
|
| 306 |
|
| 307 |
with left:
|
| 308 |
-
st.markdown('<div class="big-title">🏦
|
|
|
|
| 309 |
|
| 310 |
with right:
|
| 311 |
if st.session_state.get("user_id"):
|
|
@@ -318,6 +645,9 @@ with right:
|
|
| 318 |
|
| 319 |
st.divider()
|
| 320 |
|
|
|
|
|
|
|
|
|
|
| 321 |
if not st.session_state.get("user_id"):
|
| 322 |
colA, colB = st.columns(2, gap="large")
|
| 323 |
|
|
@@ -335,22 +665,75 @@ if not st.session_state.get("user_id"):
|
|
| 335 |
else:
|
| 336 |
st.error(msg)
|
| 337 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
with colB:
|
| 339 |
st.markdown("### Open account (Register)")
|
| 340 |
with st.form("register_form", clear_on_submit=True):
|
| 341 |
full_name = st.text_input("Full name", placeholder="Md Ahsan Habib")
|
| 342 |
email = st.text_input("Email", placeholder="you@example.com")
|
| 343 |
-
password = st.text_input("Password", type="password")
|
| 344 |
nickname = st.text_input("First account nickname", value="Checking")
|
| 345 |
-
submit = st.form_submit_button("
|
| 346 |
if submit:
|
| 347 |
ok, msg = open_account(full_name, email, password, nickname)
|
| 348 |
if ok:
|
| 349 |
st.success(msg)
|
| 350 |
-
st.info("Now
|
| 351 |
else:
|
| 352 |
st.error(msg)
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
else:
|
| 355 |
require_login()
|
| 356 |
session = db_session()
|
|
@@ -365,7 +748,7 @@ else:
|
|
| 365 |
st.markdown('<div class="card">', unsafe_allow_html=True)
|
| 366 |
st.markdown("**Total balance**")
|
| 367 |
st.markdown(f"### {money(total_balance)}")
|
| 368 |
-
st.markdown('<div class="small">Across all your
|
| 369 |
st.markdown("</div>", unsafe_allow_html=True)
|
| 370 |
|
| 371 |
with c2:
|
|
@@ -404,10 +787,15 @@ else:
|
|
| 404 |
if submit:
|
| 405 |
try:
|
| 406 |
acct_num = generate_account_number(session)
|
| 407 |
-
acct = BankAccount(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
session.add(acct)
|
| 409 |
session.commit()
|
| 410 |
-
st.success(f"Created new account: {nickname} — {acct_num}")
|
| 411 |
st.rerun()
|
| 412 |
except Exception as e:
|
| 413 |
session.rollback()
|
|
@@ -440,7 +828,7 @@ else:
|
|
| 440 |
else:
|
| 441 |
with st.form("transfer_form"):
|
| 442 |
from_label = st.selectbox("From account", list(from_choices.keys()))
|
| 443 |
-
to_acct = st.text_input("To account number", placeholder="12-digit
|
| 444 |
amount = st.number_input("Amount", min_value=0.0, value=25.0, step=5.0)
|
| 445 |
note = st.text_input("Note (optional)", placeholder="Rent, gift, etc.")
|
| 446 |
submit = st.form_submit_button("Transfer")
|
|
@@ -498,7 +886,7 @@ else:
|
|
| 498 |
meta = {}
|
| 499 |
try:
|
| 500 |
meta = json.loads(t.meta or "{}")
|
| 501 |
-
except:
|
| 502 |
meta = {}
|
| 503 |
rows.append({
|
| 504 |
"Time (UTC)": t.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
|
| 1 |
import os
|
| 2 |
import json
|
| 3 |
import secrets
|
| 4 |
+
import smtplib
|
| 5 |
+
import ssl
|
| 6 |
+
from email.message import EmailMessage
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
|
| 9 |
import streamlit as st
|
| 10 |
from sqlalchemy import (
|
| 11 |
+
create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean
|
| 12 |
)
|
| 13 |
from sqlalchemy.orm import declarative_base, sessionmaker, relationship
|
| 14 |
from passlib.context import CryptContext
|
| 15 |
|
| 16 |
+
|
| 17 |
+
# ----------------------------
|
| 18 |
+
# App Config
|
| 19 |
+
# ----------------------------
|
| 20 |
+
APP_NAME = "Bank AHR"
|
| 21 |
+
|
| 22 |
+
# Email / SMTP settings (set these in your hosting Secrets/Env)
|
| 23 |
+
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
| 24 |
+
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
|
| 25 |
+
SMTP_USER = os.getenv("SMTP_USER", "")
|
| 26 |
+
SMTP_PASS = os.getenv("SMTP_PASS", "")
|
| 27 |
+
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
|
| 28 |
+
|
| 29 |
+
# Optional: for development only (shows codes in UI when email isn't configured)
|
| 30 |
+
DEV_MODE = os.getenv("DEV_MODE", "false").lower() in ("1", "true", "yes")
|
| 31 |
+
|
| 32 |
+
# Verification settings
|
| 33 |
+
CODE_TTL_MINUTES = int(os.getenv("CODE_TTL_MINUTES", "15")) # code expires after 15 min
|
| 34 |
+
RESEND_COOLDOWN_SECONDS = int(os.getenv("RESEND_COOLDOWN_SECONDS", "60")) # resend cooldown
|
| 35 |
+
|
| 36 |
+
|
| 37 |
# ----------------------------
|
| 38 |
+
# Config: DB path (persistent)
|
| 39 |
# ----------------------------
|
| 40 |
# Hugging Face Spaces persistent storage (if enabled) is typically mounted at /data
|
|
|
|
| 41 |
DB_DIR = "/data" if os.path.isdir("/data") else "."
|
| 42 |
+
DB_PATH = os.path.join(DB_DIR, "bank_ahr.db")
|
| 43 |
|
| 44 |
engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False})
|
| 45 |
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
|
|
|
| 59 |
password_hash = Column(String, nullable=False)
|
| 60 |
created_at = Column(DateTime, default=datetime.utcnow)
|
| 61 |
|
| 62 |
+
# Email verification
|
| 63 |
+
email_verified = Column(Boolean, default=False, nullable=False)
|
| 64 |
+
email_verification_code_hash = Column(String, nullable=True)
|
| 65 |
+
email_verification_sent_at = Column(DateTime, nullable=True)
|
| 66 |
+
|
| 67 |
+
# Password reset
|
| 68 |
+
password_reset_code_hash = Column(String, nullable=True)
|
| 69 |
+
password_reset_sent_at = Column(DateTime, nullable=True)
|
| 70 |
+
|
| 71 |
accounts = relationship("BankAccount", back_populates="owner", cascade="all, delete-orphan")
|
| 72 |
payees = relationship("Payee", back_populates="owner", cascade="all, delete-orphan")
|
| 73 |
|
|
|
|
| 114 |
Base.metadata.create_all(bind=engine)
|
| 115 |
|
| 116 |
|
| 117 |
+
# ----------------------------
|
| 118 |
+
# Lightweight schema upgrade (SQLite ALTER TABLE for new columns)
|
| 119 |
+
# ----------------------------
|
| 120 |
+
def ensure_schema():
|
| 121 |
+
"""
|
| 122 |
+
If an older DB exists, add missing columns without needing migrations.
|
| 123 |
+
"""
|
| 124 |
+
session = SessionLocal()
|
| 125 |
+
try:
|
| 126 |
+
# Check columns in users table
|
| 127 |
+
cols = session.execute("PRAGMA table_info(users);").fetchall()
|
| 128 |
+
col_names = {c[1] for c in cols} # c[1] is column name
|
| 129 |
+
|
| 130 |
+
def add_col(sql):
|
| 131 |
+
session.execute(sql)
|
| 132 |
+
|
| 133 |
+
if "email_verified" not in col_names:
|
| 134 |
+
add_col("ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT 0;")
|
| 135 |
+
if "email_verification_code_hash" not in col_names:
|
| 136 |
+
add_col("ALTER TABLE users ADD COLUMN email_verification_code_hash VARCHAR;")
|
| 137 |
+
if "email_verification_sent_at" not in col_names:
|
| 138 |
+
add_col("ALTER TABLE users ADD COLUMN email_verification_sent_at DATETIME;")
|
| 139 |
+
if "password_reset_code_hash" not in col_names:
|
| 140 |
+
add_col("ALTER TABLE users ADD COLUMN password_reset_code_hash VARCHAR;")
|
| 141 |
+
if "password_reset_sent_at" not in col_names:
|
| 142 |
+
add_col("ALTER TABLE users ADD COLUMN password_reset_sent_at DATETIME;")
|
| 143 |
+
|
| 144 |
+
session.commit()
|
| 145 |
+
except Exception:
|
| 146 |
+
session.rollback()
|
| 147 |
+
# If PRAGMA/ALTER fails for any reason, app still runs with fresh DB
|
| 148 |
+
finally:
|
| 149 |
+
session.close()
|
| 150 |
+
|
| 151 |
+
ensure_schema()
|
| 152 |
+
|
| 153 |
+
|
| 154 |
# ----------------------------
|
| 155 |
# Helpers
|
| 156 |
# ----------------------------
|
| 157 |
+
def db_session():
|
| 158 |
+
return SessionLocal()
|
| 159 |
+
|
| 160 |
def hash_password(pw: str) -> str:
|
| 161 |
return pwd_context.hash(pw)
|
| 162 |
|
| 163 |
def verify_password(pw: str, hashed: str) -> bool:
|
| 164 |
return pwd_context.verify(pw, hashed)
|
| 165 |
|
| 166 |
+
def hash_code(code: str) -> str:
|
| 167 |
+
return pwd_context.hash(code)
|
| 168 |
|
| 169 |
+
def verify_code(code: str, hashed: str) -> bool:
|
| 170 |
+
return pwd_context.verify(code, hashed)
|
| 171 |
+
|
| 172 |
+
def generate_otp_code() -> str:
|
| 173 |
+
# 6-digit numeric code
|
| 174 |
+
return f"{secrets.randbelow(1_000_000):06d}"
|
|
|
|
| 175 |
|
| 176 |
def money(x: float) -> str:
|
| 177 |
return f"${x:,.2f}"
|
|
|
|
| 182 |
st.stop()
|
| 183 |
|
| 184 |
def get_user(session):
|
| 185 |
+
return session.get(User, st.session_state["user_id"])
|
| 186 |
|
| 187 |
def get_user_accounts(session, user_id: int):
|
| 188 |
return session.query(BankAccount).filter_by(owner_id=user_id).order_by(BankAccount.created_at.asc()).all()
|
| 189 |
|
| 190 |
+
def can_resend(last_sent_at: datetime | None) -> tuple[bool, str]:
|
| 191 |
+
if not last_sent_at:
|
| 192 |
+
return True, ""
|
| 193 |
+
delta = datetime.utcnow() - last_sent_at
|
| 194 |
+
if delta.total_seconds() >= RESEND_COOLDOWN_SECONDS:
|
| 195 |
+
return True, ""
|
| 196 |
+
wait = int(RESEND_COOLDOWN_SECONDS - delta.total_seconds())
|
| 197 |
+
return False, f"Please wait {wait}s before requesting another code."
|
| 198 |
+
|
| 199 |
+
def is_code_expired(sent_at: datetime | None) -> bool:
|
| 200 |
+
if not sent_at:
|
| 201 |
+
return True
|
| 202 |
+
return datetime.utcnow() > (sent_at + timedelta(minutes=CODE_TTL_MINUTES))
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ----------------------------
|
| 206 |
+
# Email sending
|
| 207 |
+
# ----------------------------
|
| 208 |
+
def smtp_configured() -> bool:
|
| 209 |
+
return bool(SMTP_HOST and SMTP_PORT and SMTP_USER and SMTP_PASS and SMTP_FROM)
|
| 210 |
+
|
| 211 |
+
def send_email(to_email: str, subject: str, body: str) -> tuple[bool, str]:
|
| 212 |
+
"""
|
| 213 |
+
Sends email via SMTP. Requires SMTP_* env vars.
|
| 214 |
+
"""
|
| 215 |
+
if not smtp_configured():
|
| 216 |
+
if DEV_MODE:
|
| 217 |
+
return False, "SMTP not configured (DEV_MODE enabled)."
|
| 218 |
+
return False, "Email service is not configured. Please set SMTP secrets."
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
msg = EmailMessage()
|
| 222 |
+
msg["From"] = SMTP_FROM
|
| 223 |
+
msg["To"] = to_email
|
| 224 |
+
msg["Subject"] = subject
|
| 225 |
+
msg.set_content(body)
|
| 226 |
+
|
| 227 |
+
context = ssl.create_default_context()
|
| 228 |
+
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
|
| 229 |
+
server.starttls(context=context)
|
| 230 |
+
server.login(SMTP_USER, SMTP_PASS)
|
| 231 |
+
server.send_message(msg)
|
| 232 |
+
return True, "Email sent."
|
| 233 |
+
except Exception as e:
|
| 234 |
+
return False, f"Failed to send email: {e}"
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ----------------------------
|
| 238 |
+
# Verification & Reset flows
|
| 239 |
+
# ----------------------------
|
| 240 |
+
def request_email_verification(email: str) -> tuple[bool, str, str | None]:
|
| 241 |
+
"""
|
| 242 |
+
Generates and emails a verification code, storing hash + timestamp.
|
| 243 |
+
Returns (ok, msg, raw_code_if_dev)
|
| 244 |
+
"""
|
| 245 |
+
session = db_session()
|
| 246 |
+
raw_code = None
|
| 247 |
+
try:
|
| 248 |
+
user = session.query(User).filter_by(email=email.lower().strip()).first()
|
| 249 |
+
if not user:
|
| 250 |
+
return False, "No account found for that email.", None
|
| 251 |
+
if user.email_verified:
|
| 252 |
+
return True, "Email already verified.", None
|
| 253 |
+
|
| 254 |
+
ok_resend, msg_resend = can_resend(user.email_verification_sent_at)
|
| 255 |
+
if not ok_resend:
|
| 256 |
+
return False, msg_resend, None
|
| 257 |
+
|
| 258 |
+
raw_code = generate_otp_code()
|
| 259 |
+
user.email_verification_code_hash = hash_code(raw_code)
|
| 260 |
+
user.email_verification_sent_at = datetime.utcnow()
|
| 261 |
+
session.commit()
|
| 262 |
+
|
| 263 |
+
subject = f"{APP_NAME} - Email Verification Code"
|
| 264 |
+
body = (
|
| 265 |
+
f"Your {APP_NAME} verification code is: {raw_code}\n\n"
|
| 266 |
+
f"This code expires in {CODE_TTL_MINUTES} minutes.\n"
|
| 267 |
+
"If you did not request this, you can ignore this email."
|
| 268 |
+
)
|
| 269 |
+
sent, send_msg = send_email(user.email, subject, body)
|
| 270 |
+
|
| 271 |
+
if sent:
|
| 272 |
+
return True, "Verification code sent to your email.", (raw_code if DEV_MODE else None)
|
| 273 |
+
|
| 274 |
+
# If not sent and DEV_MODE is on, we still allow showing the code
|
| 275 |
+
if DEV_MODE:
|
| 276 |
+
return True, f"{send_msg} (Showing code in DEV_MODE)", raw_code
|
| 277 |
+
|
| 278 |
+
return False, send_msg, None
|
| 279 |
+
except Exception as e:
|
| 280 |
+
session.rollback()
|
| 281 |
+
return False, f"Error: {e}", None
|
| 282 |
+
finally:
|
| 283 |
+
session.close()
|
| 284 |
+
|
| 285 |
+
def verify_email_with_code(email: str, code: str) -> tuple[bool, str]:
|
| 286 |
+
session = db_session()
|
| 287 |
+
try:
|
| 288 |
+
user = session.query(User).filter_by(email=email.lower().strip()).first()
|
| 289 |
+
if not user:
|
| 290 |
+
return False, "No account found for that email."
|
| 291 |
+
if user.email_verified:
|
| 292 |
+
return True, "Email is already verified."
|
| 293 |
+
|
| 294 |
+
if not user.email_verification_code_hash or not user.email_verification_sent_at:
|
| 295 |
+
return False, "No verification code requested yet. Please request a new code."
|
| 296 |
+
|
| 297 |
+
if is_code_expired(user.email_verification_sent_at):
|
| 298 |
+
return False, "Code expired. Please request a new verification code."
|
| 299 |
+
|
| 300 |
+
if not verify_code(code.strip(), user.email_verification_code_hash):
|
| 301 |
+
return False, "Invalid code."
|
| 302 |
+
|
| 303 |
+
user.email_verified = True
|
| 304 |
+
user.email_verification_code_hash = None
|
| 305 |
+
user.email_verification_sent_at = None
|
| 306 |
+
session.commit()
|
| 307 |
+
return True, "Email verified successfully! You can now log in."
|
| 308 |
+
except Exception as e:
|
| 309 |
+
session.rollback()
|
| 310 |
+
return False, f"Error: {e}"
|
| 311 |
+
finally:
|
| 312 |
+
session.close()
|
| 313 |
+
|
| 314 |
+
def request_password_reset(email: str) -> tuple[bool, str, str | None]:
|
| 315 |
+
"""
|
| 316 |
+
Sends reset code and stores hash.
|
| 317 |
+
"""
|
| 318 |
+
session = db_session()
|
| 319 |
+
raw_code = None
|
| 320 |
+
try:
|
| 321 |
+
user = session.query(User).filter_by(email=email.lower().strip()).first()
|
| 322 |
+
# Do not reveal whether account exists in real apps; but per your request we keep it straightforward.
|
| 323 |
+
if not user:
|
| 324 |
+
return False, "No account found for that email.", None
|
| 325 |
+
|
| 326 |
+
ok_resend, msg_resend = can_resend(user.password_reset_sent_at)
|
| 327 |
+
if not ok_resend:
|
| 328 |
+
return False, msg_resend, None
|
| 329 |
+
|
| 330 |
+
raw_code = generate_otp_code()
|
| 331 |
+
user.password_reset_code_hash = hash_code(raw_code)
|
| 332 |
+
user.password_reset_sent_at = datetime.utcnow()
|
| 333 |
+
session.commit()
|
| 334 |
+
|
| 335 |
+
subject = f"{APP_NAME} - Password Reset Code"
|
| 336 |
+
body = (
|
| 337 |
+
f"Your {APP_NAME} password reset code is: {raw_code}\n\n"
|
| 338 |
+
f"This code expires in {CODE_TTL_MINUTES} minutes.\n"
|
| 339 |
+
"If you did not request this, you can ignore this email."
|
| 340 |
+
)
|
| 341 |
+
sent, send_msg = send_email(user.email, subject, body)
|
| 342 |
+
|
| 343 |
+
if sent:
|
| 344 |
+
return True, "Password reset code sent to your email.", (raw_code if DEV_MODE else None)
|
| 345 |
+
|
| 346 |
+
if DEV_MODE:
|
| 347 |
+
return True, f"{send_msg} (Showing code in DEV_MODE)", raw_code
|
| 348 |
+
|
| 349 |
+
return False, send_msg, None
|
| 350 |
+
except Exception as e:
|
| 351 |
+
session.rollback()
|
| 352 |
+
return False, f"Error: {e}", None
|
| 353 |
+
finally:
|
| 354 |
+
session.close()
|
| 355 |
+
|
| 356 |
+
def reset_password_with_code(email: str, code: str, new_password: str) -> tuple[bool, str]:
|
| 357 |
+
session = db_session()
|
| 358 |
+
try:
|
| 359 |
+
user = session.query(User).filter_by(email=email.lower().strip()).first()
|
| 360 |
+
if not user:
|
| 361 |
+
return False, "No account found for that email."
|
| 362 |
+
|
| 363 |
+
if not user.password_reset_code_hash or not user.password_reset_sent_at:
|
| 364 |
+
return False, "No reset code requested yet."
|
| 365 |
+
|
| 366 |
+
if is_code_expired(user.password_reset_sent_at):
|
| 367 |
+
return False, "Reset code expired. Please request a new one."
|
| 368 |
+
|
| 369 |
+
if not verify_code(code.strip(), user.password_reset_code_hash):
|
| 370 |
+
return False, "Invalid reset code."
|
| 371 |
+
|
| 372 |
+
if len(new_password) < 6:
|
| 373 |
+
return False, "Password should be at least 6 characters."
|
| 374 |
+
|
| 375 |
+
user.password_hash = hash_password(new_password)
|
| 376 |
+
user.password_reset_code_hash = None
|
| 377 |
+
user.password_reset_sent_at = None
|
| 378 |
+
session.commit()
|
| 379 |
+
return True, "Password updated successfully! You can now log in."
|
| 380 |
+
except Exception as e:
|
| 381 |
+
session.rollback()
|
| 382 |
+
return False, f"Error: {e}"
|
| 383 |
+
finally:
|
| 384 |
+
session.close()
|
| 385 |
+
|
| 386 |
|
| 387 |
# ----------------------------
|
| 388 |
# Core banking operations
|
| 389 |
# ----------------------------
|
| 390 |
+
def generate_account_number(session) -> str:
|
| 391 |
+
# 12-digit random number, ensure uniqueness
|
| 392 |
+
while True:
|
| 393 |
+
acct = "".join(str(secrets.randbelow(10)) for _ in range(12))
|
| 394 |
+
exists = session.query(BankAccount).filter_by(account_number=acct).first()
|
| 395 |
+
if not exists:
|
| 396 |
+
return acct
|
| 397 |
+
|
| 398 |
def open_account(full_name: str, email: str, password: str, nickname: str = "Checking"):
|
| 399 |
session = db_session()
|
| 400 |
try:
|
| 401 |
+
email_clean = email.lower().strip()
|
| 402 |
+
if session.query(User).filter_by(email=email_clean).first():
|
| 403 |
return False, "Email already registered."
|
| 404 |
|
| 405 |
+
if len(password) < 6:
|
| 406 |
+
return False, "Password should be at least 6 characters."
|
| 407 |
+
|
| 408 |
user = User(
|
| 409 |
full_name=full_name.strip(),
|
| 410 |
+
email=email_clean,
|
| 411 |
password_hash=hash_password(password),
|
| 412 |
+
email_verified=False,
|
| 413 |
)
|
| 414 |
session.add(user)
|
| 415 |
session.flush()
|
|
|
|
| 422 |
balance=0.0,
|
| 423 |
)
|
| 424 |
session.add(acct)
|
| 425 |
+
|
| 426 |
+
# Create verification code on register
|
| 427 |
+
raw_code = generate_otp_code()
|
| 428 |
+
user.email_verification_code_hash = hash_code(raw_code)
|
| 429 |
+
user.email_verification_sent_at = datetime.utcnow()
|
| 430 |
+
|
| 431 |
session.commit()
|
| 432 |
+
|
| 433 |
+
# Send verification email
|
| 434 |
+
subject = f"{APP_NAME} - Email Verification Code"
|
| 435 |
+
body = (
|
| 436 |
+
f"Welcome to {APP_NAME}!\n\n"
|
| 437 |
+
f"Your verification code is: {raw_code}\n\n"
|
| 438 |
+
f"This code expires in {CODE_TTL_MINUTES} minutes."
|
| 439 |
+
)
|
| 440 |
+
sent, send_msg = send_email(user.email, subject, body)
|
| 441 |
+
|
| 442 |
+
if sent:
|
| 443 |
+
return True, (
|
| 444 |
+
f"Account created! Your new account number: {acct_num}\n"
|
| 445 |
+
f"A verification code has been sent to your email."
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
if DEV_MODE:
|
| 449 |
+
return True, (
|
| 450 |
+
f"Account created! Your new account number: {acct_num}\n"
|
| 451 |
+
f"{send_msg}\n"
|
| 452 |
+
f"DEV_MODE code: {raw_code}"
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
return True, (
|
| 456 |
+
f"Account created! Your new account number: {acct_num}\n"
|
| 457 |
+
f"NOTE: {send_msg}\n"
|
| 458 |
+
f"Ask admin to configure SMTP so you can verify email."
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
except Exception as e:
|
| 462 |
session.rollback()
|
| 463 |
return False, f"Error: {e}"
|
|
|
|
| 471 |
if not user or not verify_password(password, user.password_hash):
|
| 472 |
return False, "Invalid email or password."
|
| 473 |
|
| 474 |
+
if not user.email_verified:
|
| 475 |
+
return False, "Email not verified. Please verify your email before logging in."
|
| 476 |
+
|
| 477 |
st.session_state["user_id"] = user.id
|
| 478 |
st.session_state["user_name"] = user.full_name
|
| 479 |
return True, f"Welcome, {user.full_name}!"
|
|
|
|
| 491 |
|
| 492 |
session = db_session()
|
| 493 |
try:
|
| 494 |
+
acct = session.query(BankAccount).filter_by(
|
| 495 |
+
owner_id=user_id, account_number=account_number
|
| 496 |
+
).with_for_update().first()
|
| 497 |
if not acct:
|
| 498 |
return False, "Account not found."
|
| 499 |
|
|
|
|
| 523 |
|
| 524 |
session = db_session()
|
| 525 |
try:
|
| 526 |
+
from_acct = session.query(BankAccount).filter_by(
|
| 527 |
+
owner_id=user_id, account_number=from_acct_num
|
| 528 |
+
).with_for_update().first()
|
| 529 |
if not from_acct:
|
| 530 |
return False, "Source account not found."
|
| 531 |
|
| 532 |
+
to_acct = session.query(BankAccount).filter_by(
|
| 533 |
+
account_number=to_acct_num
|
| 534 |
+
).with_for_update().first()
|
| 535 |
if not to_acct:
|
| 536 |
return False, "Destination account number not found."
|
| 537 |
|
|
|
|
| 578 |
|
| 579 |
session = db_session()
|
| 580 |
try:
|
| 581 |
+
acct = session.query(BankAccount).filter_by(
|
| 582 |
+
owner_id=user_id, account_number=from_acct_num
|
| 583 |
+
).with_for_update().first()
|
| 584 |
if not acct:
|
| 585 |
return False, "Account not found."
|
| 586 |
|
|
|
|
| 613 |
# ----------------------------
|
| 614 |
# UI
|
| 615 |
# ----------------------------
|
| 616 |
+
st.set_page_config(page_title=APP_NAME, page_icon="🏦", layout="wide")
|
| 617 |
|
| 618 |
st.markdown(
|
| 619 |
"""
|
|
|
|
| 622 |
.subtle { color: #6b7280; margin-top: 0; }
|
| 623 |
.card { padding: 1rem; border-radius: 14px; border: 1px solid #e5e7eb; background: #fff; }
|
| 624 |
.small { font-size: 0.9rem; color: #6b7280; }
|
| 625 |
+
.codehint { font-size: 0.9rem; padding: 0.75rem; border-radius: 10px; border: 1px dashed #e5e7eb; }
|
| 626 |
</style>
|
| 627 |
""",
|
| 628 |
unsafe_allow_html=True,
|
|
|
|
| 631 |
left, right = st.columns([3, 2], gap="large")
|
| 632 |
|
| 633 |
with left:
|
| 634 |
+
st.markdown(f'<div class="big-title">🏦 {APP_NAME}</div>', unsafe_allow_html=True)
|
| 635 |
+
st.markdown('<p class="subtle">Secure banking demo with email verification</p>', unsafe_allow_html=True)
|
| 636 |
|
| 637 |
with right:
|
| 638 |
if st.session_state.get("user_id"):
|
|
|
|
| 645 |
|
| 646 |
st.divider()
|
| 647 |
|
| 648 |
+
# ----------------------------
|
| 649 |
+
# Auth Screens (not logged in)
|
| 650 |
+
# ----------------------------
|
| 651 |
if not st.session_state.get("user_id"):
|
| 652 |
colA, colB = st.columns(2, gap="large")
|
| 653 |
|
|
|
|
| 665 |
else:
|
| 666 |
st.error(msg)
|
| 667 |
|
| 668 |
+
st.markdown("#### Verify email")
|
| 669 |
+
with st.form("verify_email_form", clear_on_submit=False):
|
| 670 |
+
v_email = st.text_input("Email to verify", placeholder="you@example.com", key="verify_email_input")
|
| 671 |
+
v_code = st.text_input("Verification code (6 digits)", placeholder="123456", key="verify_code_input")
|
| 672 |
+
c1, c2 = st.columns(2)
|
| 673 |
+
with c1:
|
| 674 |
+
verify_btn = st.form_submit_button("Verify")
|
| 675 |
+
with c2:
|
| 676 |
+
resend_btn = st.form_submit_button("Resend code")
|
| 677 |
+
|
| 678 |
+
if resend_btn:
|
| 679 |
+
ok, msg, raw = request_email_verification(v_email)
|
| 680 |
+
(st.success if ok else st.error)(msg)
|
| 681 |
+
if raw and DEV_MODE:
|
| 682 |
+
st.markdown(f'<div class="codehint">DEV_MODE code: <b>{raw}</b></div>', unsafe_allow_html=True)
|
| 683 |
+
|
| 684 |
+
if verify_btn:
|
| 685 |
+
ok, msg = verify_email_with_code(v_email, v_code)
|
| 686 |
+
(st.success if ok else st.error)(msg)
|
| 687 |
+
|
| 688 |
+
st.markdown("#### Forgot password")
|
| 689 |
+
with st.form("forgot_password_form", clear_on_submit=False):
|
| 690 |
+
fp_email = st.text_input("Account email", placeholder="you@example.com", key="fp_email")
|
| 691 |
+
fp_code = st.text_input("Reset code (6 digits)", placeholder="123456", key="fp_code")
|
| 692 |
+
fp_new_pw = st.text_input("New password", type="password", key="fp_new_pw")
|
| 693 |
+
|
| 694 |
+
c1, c2 = st.columns(2)
|
| 695 |
+
with c1:
|
| 696 |
+
send_reset = st.form_submit_button("Send reset code")
|
| 697 |
+
with c2:
|
| 698 |
+
do_reset = st.form_submit_button("Reset password")
|
| 699 |
+
|
| 700 |
+
if send_reset:
|
| 701 |
+
ok, msg, raw = request_password_reset(fp_email)
|
| 702 |
+
(st.success if ok else st.error)(msg)
|
| 703 |
+
if raw and DEV_MODE:
|
| 704 |
+
st.markdown(f'<div class="codehint">DEV_MODE reset code: <b>{raw}</b></div>', unsafe_allow_html=True)
|
| 705 |
+
|
| 706 |
+
if do_reset:
|
| 707 |
+
ok, msg = reset_password_with_code(fp_email, fp_code, fp_new_pw)
|
| 708 |
+
(st.success if ok else st.error)(msg)
|
| 709 |
+
|
| 710 |
with colB:
|
| 711 |
st.markdown("### Open account (Register)")
|
| 712 |
with st.form("register_form", clear_on_submit=True):
|
| 713 |
full_name = st.text_input("Full name", placeholder="Md Ahsan Habib")
|
| 714 |
email = st.text_input("Email", placeholder="you@example.com")
|
| 715 |
+
password = st.text_input("Password (min 6 chars)", type="password")
|
| 716 |
nickname = st.text_input("First account nickname", value="Checking")
|
| 717 |
+
submit = st.form_submit_button("Create account")
|
| 718 |
if submit:
|
| 719 |
ok, msg = open_account(full_name, email, password, nickname)
|
| 720 |
if ok:
|
| 721 |
st.success(msg)
|
| 722 |
+
st.info("Now verify your email using the code, then log in.")
|
| 723 |
else:
|
| 724 |
st.error(msg)
|
| 725 |
|
| 726 |
+
if not smtp_configured():
|
| 727 |
+
st.warning(
|
| 728 |
+
"SMTP is not configured, so email codes can't be sent.\n\n"
|
| 729 |
+
"Set these environment variables/secrets:\n"
|
| 730 |
+
"- SMTP_HOST\n- SMTP_PORT (usually 587)\n- SMTP_USER\n- SMTP_PASS\n- SMTP_FROM\n\n"
|
| 731 |
+
"For local testing, you can set DEV_MODE=true to show codes in the UI."
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
# ----------------------------
|
| 735 |
+
# Main App (logged in)
|
| 736 |
+
# ----------------------------
|
| 737 |
else:
|
| 738 |
require_login()
|
| 739 |
session = db_session()
|
|
|
|
| 748 |
st.markdown('<div class="card">', unsafe_allow_html=True)
|
| 749 |
st.markdown("**Total balance**")
|
| 750 |
st.markdown(f"### {money(total_balance)}")
|
| 751 |
+
st.markdown(f'<div class="small">Across all your {APP_NAME} accounts</div>', unsafe_allow_html=True)
|
| 752 |
st.markdown("</div>", unsafe_allow_html=True)
|
| 753 |
|
| 754 |
with c2:
|
|
|
|
| 787 |
if submit:
|
| 788 |
try:
|
| 789 |
acct_num = generate_account_number(session)
|
| 790 |
+
acct = BankAccount(
|
| 791 |
+
owner_id=user.id,
|
| 792 |
+
account_number=acct_num,
|
| 793 |
+
nickname=nickname.strip() or "Savings",
|
| 794 |
+
balance=0.0
|
| 795 |
+
)
|
| 796 |
session.add(acct)
|
| 797 |
session.commit()
|
| 798 |
+
st.success(f"Created new account: {acct.nickname} — {acct_num}")
|
| 799 |
st.rerun()
|
| 800 |
except Exception as e:
|
| 801 |
session.rollback()
|
|
|
|
| 828 |
else:
|
| 829 |
with st.form("transfer_form"):
|
| 830 |
from_label = st.selectbox("From account", list(from_choices.keys()))
|
| 831 |
+
to_acct = st.text_input("To account number", placeholder="12-digit account number")
|
| 832 |
amount = st.number_input("Amount", min_value=0.0, value=25.0, step=5.0)
|
| 833 |
note = st.text_input("Note (optional)", placeholder="Rent, gift, etc.")
|
| 834 |
submit = st.form_submit_button("Transfer")
|
|
|
|
| 886 |
meta = {}
|
| 887 |
try:
|
| 888 |
meta = json.loads(t.meta or "{}")
|
| 889 |
+
except Exception:
|
| 890 |
meta = {}
|
| 891 |
rows.append({
|
| 892 |
"Time (UTC)": t.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|