Download app.py from ronylu/iBank: direct link, hf CLI and curl.
- Browser
- Download file 35.3 kB
-
https://huggingface.co/spaces/ronylu/iBank/resolve/main/app.py
- Command line
-
hf download hf://spaces/ronylu/iBank/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ronylu/iBank/resolve/main/app.py
35.3 kB
| import os | |
| import json | |
| import secrets | |
| import smtplib | |
| import ssl | |
| from email.message import EmailMessage | |
| from datetime import datetime, timedelta | |
| from urllib.parse import quote | |
| import streamlit as st | |
| from sqlalchemy import ( | |
| create_engine, Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean | |
| ) | |
| from sqlalchemy.orm import declarative_base, sessionmaker, relationship | |
| from passlib.context import CryptContext | |
| # ---------------------------- | |
| # App Config | |
| # ---------------------------- | |
| APP_NAME = "Bank AHR" | |
| # Base URL for email links (VERY IMPORTANT) | |
| # Example: https://your-space-name.hf.space | |
| APP_BASE_URL = os.getenv("APP_BASE_URL", "").strip() | |
| # Email / SMTP settings (set these in hosting Secrets/Env) | |
| SMTP_HOST = os.getenv("SMTP_HOST", "") | |
| SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) | |
| SMTP_USER = os.getenv("SMTP_USER", "") | |
| SMTP_PASS = os.getenv("SMTP_PASS", "") | |
| SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER) | |
| # Development mode: show link in UI if SMTP isn't configured | |
| DEV_MODE = os.getenv("DEV_MODE", "false").lower() in ("1", "true", "yes") | |
| # Verification settings | |
| LINK_TTL_MINUTES = int(os.getenv("LINK_TTL_MINUTES", "30")) # link expires after 30 min | |
| RESEND_COOLDOWN_SECONDS = int(os.getenv("RESEND_COOLDOWN_SECONDS", "60")) # resend cooldown | |
| # ---------------------------- | |
| # Config: DB path (persistent) | |
| # ---------------------------- | |
| DB_DIR = "/data" if os.path.isdir("/data") else "." | |
| DB_PATH = os.path.join(DB_DIR, "bank_ahr.db") | |
| engine = create_engine(f"sqlite:///{DB_PATH}", connect_args={"check_same_thread": False}) | |
| SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False) | |
| Base = declarative_base() | |
| pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") | |
| # ---------------------------- | |
| # Models | |
| # ---------------------------- | |
| class User(Base): | |
| __tablename__ = "users" | |
| id = Column(Integer, primary_key=True) | |
| full_name = Column(String, nullable=False) | |
| email = Column(String, unique=True, index=True, nullable=False) | |
| password_hash = Column(String, nullable=False) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| # Email verification (LINK token) | |
| email_verified = Column(Boolean, default=False, nullable=False) | |
| email_verification_token_hash = Column(String, nullable=True) | |
| email_verification_sent_at = Column(DateTime, nullable=True) | |
| # Password reset (LINK token) | |
| password_reset_token_hash = Column(String, nullable=True) | |
| password_reset_sent_at = Column(DateTime, nullable=True) | |
| accounts = relationship("BankAccount", back_populates="owner", cascade="all, delete-orphan") | |
| payees = relationship("Payee", back_populates="owner", cascade="all, delete-orphan") | |
| class BankAccount(Base): | |
| __tablename__ = "bank_accounts" | |
| id = Column(Integer, primary_key=True) | |
| owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) | |
| account_number = Column(String, unique=True, index=True, nullable=False) | |
| nickname = Column(String, default="Checking") | |
| balance = Column(Float, default=0.0) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| owner = relationship("User", back_populates="accounts") | |
| class Payee(Base): | |
| __tablename__ = "payees" | |
| id = Column(Integer, primary_key=True) | |
| owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) | |
| name = Column(String, nullable=False) | |
| memo = Column(String, default="") | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| owner = relationship("User", back_populates="payees") | |
| class Transaction(Base): | |
| __tablename__ = "transactions" | |
| id = Column(Integer, primary_key=True) | |
| created_at = Column(DateTime, default=datetime.utcnow, index=True) | |
| # DEPOSIT | TRANSFER | BILLPAY | |
| tx_type = Column(String, nullable=False) | |
| amount = Column(Float, nullable=False) | |
| from_account = Column(String, nullable=True) | |
| to_account = Column(String, nullable=True) | |
| meta = Column(Text, default="{}") | |
| Base.metadata.create_all(bind=engine) | |
| # ---------------------------- | |
| # Lightweight schema upgrade (older DB -> new columns) | |
| # ---------------------------- | |
| def ensure_schema(): | |
| session = SessionLocal() | |
| try: | |
| cols = session.execute("PRAGMA table_info(users);").fetchall() | |
| col_names = {c[1] for c in cols} | |
| def add_col(sql): | |
| session.execute(sql) | |
| if "email_verified" not in col_names: | |
| add_col("ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT 0;") | |
| # New link-token columns (rename-safe: add if missing) | |
| if "email_verification_token_hash" not in col_names: | |
| add_col("ALTER TABLE users ADD COLUMN email_verification_token_hash VARCHAR;") | |
| if "email_verification_sent_at" not in col_names: | |
| add_col("ALTER TABLE users ADD COLUMN email_verification_sent_at DATETIME;") | |
| if "password_reset_token_hash" not in col_names: | |
| add_col("ALTER TABLE users ADD COLUMN password_reset_token_hash VARCHAR;") | |
| if "password_reset_sent_at" not in col_names: | |
| add_col("ALTER TABLE users ADD COLUMN password_reset_sent_at DATETIME;") | |
| # If you previously used *_code_hash columns, we don't remove them (SQLite limitations). | |
| session.commit() | |
| except Exception: | |
| session.rollback() | |
| finally: | |
| session.close() | |
| ensure_schema() | |
| # ---------------------------- | |
| # Helpers | |
| # ---------------------------- | |
| def db_session(): | |
| return SessionLocal() | |
| def norm_email(email: str) -> str: | |
| return (email or "").lower().strip() | |
| def hash_password(pw: str) -> str: | |
| return pwd_context.hash(pw) | |
| def verify_password(pw: str, hashed: str) -> bool: | |
| return pwd_context.verify(pw, hashed) | |
| def hash_token(token: str) -> str: | |
| return pwd_context.hash(token) | |
| def verify_token(token: str, hashed: str) -> bool: | |
| return pwd_context.verify(token, hashed) | |
| def generate_link_token() -> str: | |
| # ~43 chars, safe for bcrypt and URLs | |
| return secrets.token_urlsafe(32) | |
| def money(x: float) -> str: | |
| return f"${x:,.2f}" | |
| def require_login(): | |
| if not st.session_state.get("user_id"): | |
| st.warning("Please log in first.") | |
| st.stop() | |
| def get_user(session): | |
| return session.get(User, st.session_state["user_id"]) | |
| def get_user_accounts(session, user_id: int): | |
| return session.query(BankAccount).filter_by(owner_id=user_id).order_by(BankAccount.created_at.asc()).all() | |
| def can_resend(last_sent_at: datetime | None) -> tuple[bool, str]: | |
| if not last_sent_at: | |
| return True, "" | |
| delta = datetime.utcnow() - last_sent_at | |
| if delta.total_seconds() >= RESEND_COOLDOWN_SECONDS: | |
| return True, "" | |
| wait = int(RESEND_COOLDOWN_SECONDS - delta.total_seconds()) | |
| return False, f"Please wait {wait}s before requesting another email." | |
| def is_token_expired(sent_at: datetime | None) -> bool: | |
| if not sent_at: | |
| return True | |
| return datetime.utcnow() > (sent_at + timedelta(minutes=LINK_TTL_MINUTES)) | |
| def build_link(action: str, email: str, token: str) -> str: | |
| """ | |
| Creates link like: | |
| https://yourdomain/?action=verify&email=...&token=... | |
| """ | |
| q_email = quote(norm_email(email)) | |
| q_token = quote(token) | |
| if APP_BASE_URL: | |
| base = APP_BASE_URL.rstrip("/") | |
| return f"{base}/?action={action}&email={q_email}&token={q_token}" | |
| # Fallback (may not work inside email clients without absolute URL) | |
| return f"/?action={action}&email={q_email}&token={q_token}" | |
| def get_query_params() -> dict: | |
| # streamlit new API | |
| try: | |
| return dict(st.query_params) | |
| except Exception: | |
| return st.experimental_get_query_params() | |
| def clear_query_params(): | |
| try: | |
| st.query_params.clear() | |
| except Exception: | |
| st.experimental_set_query_params() | |
| # ---------------------------- | |
| # Email sending | |
| # ---------------------------- | |
| def smtp_configured() -> bool: | |
| return bool(SMTP_HOST and SMTP_PORT and SMTP_USER and SMTP_PASS and SMTP_FROM) | |
| def send_email(to_email: str, subject: str, body: str) -> tuple[bool, str]: | |
| if not smtp_configured(): | |
| if DEV_MODE: | |
| return False, "SMTP not configured (DEV_MODE enabled)." | |
| return False, "Email service is not configured. Please set SMTP secrets." | |
| try: | |
| msg = EmailMessage() | |
| msg["From"] = SMTP_FROM | |
| msg["To"] = to_email | |
| msg["Subject"] = subject | |
| msg.set_content(body) | |
| context = ssl.create_default_context() | |
| with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: | |
| server.starttls(context=context) | |
| server.login(SMTP_USER, SMTP_PASS) | |
| server.send_message(msg) | |
| return True, "Email sent." | |
| except Exception as e: | |
| return False, f"Failed to send email: {e}" | |
| # ---------------------------- | |
| # Link-based Verification & Reset | |
| # ---------------------------- | |
| def send_verification_link(email: str) -> tuple[bool, str, str | None]: | |
| session = db_session() | |
| raw_token = None | |
| try: | |
| user = session.query(User).filter_by(email=norm_email(email)).first() | |
| if not user: | |
| return False, "No account found for that email.", None | |
| if user.email_verified: | |
| return True, "Email already verified.", None | |
| ok_resend, msg_resend = can_resend(user.email_verification_sent_at) | |
| if not ok_resend: | |
| return False, msg_resend, None | |
| raw_token = generate_link_token() | |
| user.email_verification_token_hash = hash_token(raw_token) | |
| user.email_verification_sent_at = datetime.utcnow() | |
| session.commit() | |
| link = build_link("verify", user.email, raw_token) | |
| subject = f"{APP_NAME} - Verify your email" | |
| body = ( | |
| f"Welcome to {APP_NAME}!\n\n" | |
| f"Click this link to verify your email:\n{link}\n\n" | |
| f"This link expires in {LINK_TTL_MINUTES} minutes.\n" | |
| "If you did not request this, ignore this email." | |
| ) | |
| sent, send_msg = send_email(user.email, subject, body) | |
| if sent: | |
| return True, "Verification link sent to your email.", (link if DEV_MODE else None) | |
| if DEV_MODE: | |
| return True, f"{send_msg} (Showing link in DEV_MODE)", link | |
| return False, send_msg, None | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}", None | |
| finally: | |
| session.close() | |
| def verify_email_by_link(email: str, token: str) -> tuple[bool, str]: | |
| session = db_session() | |
| try: | |
| user = session.query(User).filter_by(email=norm_email(email)).first() | |
| if not user: | |
| return False, "Invalid verification link." | |
| if user.email_verified: | |
| return True, "Email already verified. You can log in." | |
| if not user.email_verification_token_hash or not user.email_verification_sent_at: | |
| return False, "No verification request found. Please request a new link." | |
| if is_token_expired(user.email_verification_sent_at): | |
| return False, "Verification link expired. Please request a new link." | |
| if not verify_token(token, user.email_verification_token_hash): | |
| return False, "Invalid verification link." | |
| user.email_verified = True | |
| user.email_verification_token_hash = None | |
| user.email_verification_sent_at = None | |
| session.commit() | |
| return True, "Email verified successfully! You can now log in." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| def send_password_reset_link(email: str) -> tuple[bool, str, str | None]: | |
| """ | |
| Privacy-safe: always returns generic success message. | |
| If user exists, it sends link + stores token hash. | |
| """ | |
| session = db_session() | |
| raw_token = None | |
| try: | |
| user = session.query(User).filter_by(email=norm_email(email)).first() | |
| generic_msg = "If an account exists for that email, a reset link has been sent." | |
| if not user: | |
| return True, generic_msg, None | |
| ok_resend, msg_resend = can_resend(user.password_reset_sent_at) | |
| if not ok_resend: | |
| return False, msg_resend, None | |
| raw_token = generate_link_token() | |
| user.password_reset_token_hash = hash_token(raw_token) | |
| user.password_reset_sent_at = datetime.utcnow() | |
| session.commit() | |
| link = build_link("reset", user.email, raw_token) | |
| subject = f"{APP_NAME} - Reset your password" | |
| body = ( | |
| f"Click this link to reset your password:\n{link}\n\n" | |
| f"This link expires in {LINK_TTL_MINUTES} minutes.\n" | |
| "If you did not request this,FEST, ignore this email." | |
| ) | |
| sent, send_msg = send_email(user.email, subject, body) | |
| if sent: | |
| return True, generic_msg, (link if DEV_MODE else None) | |
| if DEV_MODE: | |
| return True, f"{send_msg} (Showing link in DEV_MODE)", link | |
| return True, generic_msg, None | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}", None | |
| finally: | |
| session.close() | |
| def reset_password_by_link(email: str, token: str, new_password: str) -> tuple[bool, str]: | |
| session = db_session() | |
| try: | |
| user = session.query(User).filter_by(email=norm_email(email)).first() | |
| if not user: | |
| return False, "Invalid reset link." | |
| if not user.password_reset_token_hash or not user.password_reset_sent_at: | |
| return False, "No reset request found. Please request a new reset link." | |
| if is_token_expired(user.password_reset_sent_at): | |
| return False, "Reset link expired. Please request a new one." | |
| if not verify_token(token, user.password_reset_token_hash): | |
| return False, "Invalid reset link." | |
| if len(new_password) < 6: | |
| return False, "Password must be at least 6 characters." | |
| user.password_hash = hash_password(new_password) | |
| user.password_reset_token_hash = None | |
| user.password_reset_sent_at = None | |
| session.commit() | |
| return True, "Password reset successfully! You can now log in." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| # ---------------------------- | |
| # Banking Core | |
| # ---------------------------- | |
| def generate_account_number(session) -> str: | |
| while True: | |
| acct = "".join(str(secrets.randbelow(10)) for _ in range(12)) | |
| exists = session.query(BankAccount).filter_by(account_number=acct).first() | |
| if not exists: | |
| return acct | |
| def open_account(full_name: str, email: str, password: str, nickname: str = "Checking"): | |
| session = db_session() | |
| try: | |
| email_clean = norm_email(email) | |
| if session.query(User).filter_by(email=email_clean).first(): | |
| return False, "Email already registered." | |
| if len(password) < 6: | |
| return False, "Password should be at least 6 characters." | |
| user = User( | |
| full_name=full_name.strip(), | |
| email=email_clean, | |
| password_hash=hash_password(password), | |
| email_verified=False, | |
| ) | |
| session.add(user) | |
| session.flush() | |
| acct_num = generate_account_number(session) | |
| acct = BankAccount( | |
| owner_id=user.id, | |
| account_number=acct_num, | |
| nickname=nickname.strip() if nickname.strip() else "Checking", | |
| balance=0.0, | |
| ) | |
| session.add(acct) | |
| session.commit() | |
| # Send verification link immediately | |
| ok, msg, dev_link = send_verification_link(user.email) | |
| if ok: | |
| return True, f"Account created! Account number: {acct_num}\n{msg}" + (f"\nDEV_LINK: {dev_link}" if dev_link else "") | |
| return True, f"Account created! Account number: {acct_num}\nNOTE: {msg}" | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| def login(email: str, password: str): | |
| """ | |
| Returns: (ok: bool, msg: str, status: str) | |
| status: OK | INVALID | UNVERIFIED | |
| """ | |
| session = db_session() | |
| try: | |
| user = session.query(User).filter_by(email=norm_email(email)).first() | |
| if not user or not verify_password(password, user.password_hash): | |
| return False, "Invalid email or password.", "INVALID" | |
| if not user.email_verified: | |
| return False, "Email not verified. Please verify your email (link sent).", "UNVERIFIED" | |
| st.session_state["user_id"] = user.id | |
| st.session_state["user_name"] = user.full_name | |
| return True, f"Welcome, {user.full_name}!", "OK" | |
| finally: | |
| session.close() | |
| def logout(): | |
| for k in ["user_id", "user_name", "pending_verify_email"]: | |
| if k in st.session_state: | |
| del st.session_state[k] | |
| def change_password(user_id: int, current_pw: str, new_pw: str) -> tuple[bool, str]: | |
| session = db_session() | |
| try: | |
| user = session.get(User, user_id) | |
| if not user: | |
| return False, "User not found." | |
| if not verify_password(current_pw, user.password_hash): | |
| return False, "Current password is incorrect." | |
| if len(new_pw) < 6: | |
| return False, "New password must be at least 6 characters." | |
| user.password_hash = hash_password(new_pw) | |
| session.commit() | |
| return True, "Password changed successfully." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| def deposit(user_id: int, account_number: str, amount: float, note: str = ""): | |
| if amount <= 0: | |
| return False, "Deposit must be greater than 0." | |
| session = db_session() | |
| try: | |
| acct = session.query(BankAccount).filter_by(owner_id=user_id, account_number=account_number).with_for_update().first() | |
| if not acct: | |
| return False, "Account not found." | |
| acct.balance += amount | |
| tx = Transaction( | |
| tx_type="DEPOSIT", | |
| amount=amount, | |
| from_account=None, | |
| to_account=account_number, | |
| meta=json.dumps({"note": note.strip()}), | |
| ) | |
| session.add(tx) | |
| session.commit() | |
| return True, f"Deposited {money(amount)} to {account_number}." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| def transfer(user_id: int, from_acct_num: str, to_acct_num: str, amount: float, note: str = ""): | |
| if amount <= 0: | |
| return False, "Transfer amount must be greater than 0." | |
| if from_acct_num == to_acct_num: | |
| return False, "From and To accounts cannot be the same." | |
| session = db_session() | |
| try: | |
| from_acct = session.query(BankAccount).filter_by(owner_id=user_id, account_number=from_acct_num).with_for_update().first() | |
| if not from_acct: | |
| return False, "Source account not found." | |
| to_acct = session.query(BankAccount).filter_by(account_number=to_acct_num).with_for_update().first() | |
| if not to_acct: | |
| return False, "Destination account number not found." | |
| if from_acct.balance < amount: | |
| return False, "Insufficient funds." | |
| from_acct.balance -= amount | |
| to_acct.balance += amount | |
| tx = Transaction( | |
| tx_type="TRANSFER", | |
| amount=amount, | |
| from_account=from_acct_num, | |
| to_account=to_acct_num, | |
| meta=json.dumps({"note": note.strip()}), | |
| ) | |
| session.add(tx) | |
| session.commit() | |
| return True, f"Transferred {money(amount)} from {from_acct_num} to {to_acct_num}." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| def add_payee(user_id: int, name: str, memo: str = ""): | |
| if not name.strip(): | |
| return False, "Payee name is required." | |
| session = db_session() | |
| try: | |
| payee = Payee(owner_id=user_id, name=name.strip(), memo=memo.strip()) | |
| session.add(payee) | |
| session.commit() | |
| return True, f"Payee '{payee.name}' added." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| def pay_bill(user_id: int, from_acct_num: str, payee_id: int, amount: float, note: str = ""): | |
| if amount <= 0: | |
| return False, "Payment amount must be greater than 0." | |
| session = db_session() | |
| try: | |
| acct = session.query(BankAccount).filter_by(owner_id=user_id, account_number=from_acct_num).with_for_update().first() | |
| if not acct: | |
| return False, "Account not found." | |
| payee = session.query(Payee).filter_by(owner_id=user_id, id=payee_id).first() | |
| if not payee: | |
| return False, "Payee not found." | |
| if acct.balance < amount: | |
| return False, "Insufficient funds." | |
| acct.balance -= amount | |
| tx = Transaction( | |
| tx_type="BILLPAY", | |
| amount=amount, | |
| from_account=from_acct_num, | |
| to_account=None, | |
| meta=json.dumps({"payee": payee.name, "note": note.strip()}), | |
| ) | |
| session.add(tx) | |
| session.commit() | |
| return True, f"Paid {money(amount)} to {payee.name} from {from_acct_num}." | |
| except Exception as e: | |
| session.rollback() | |
| return False, f"Error: {e}" | |
| finally: | |
| session.close() | |
| # ---------------------------- | |
| # UI | |
| # ---------------------------- | |
| st.set_page_config(page_title=APP_NAME, page_icon="🏦", layout="wide") | |
| st.markdown( | |
| """ | |
| <style> | |
| .big-title { font-size: 40px; font-weight: 800; margin-bottom: 0.25rem; } | |
| .subtle { color: #6b7280; margin-top: 0; } | |
| .card { padding: 1rem; border-radius: 14px; border: 1px solid #e5e7eb; background: #fff; } | |
| .small { font-size: 0.9rem; color: #6b7280; } | |
| .hint { font-size: 0.9rem; padding: 0.75rem; border-radius: 10px; border: 1px dashed #e5e7eb; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # Handle verification/reset link clicks | |
| params = get_query_params() | |
| action = (params.get("action", [""]) if isinstance(params.get("action"), list) else params.get("action", "")).strip() | |
| q_email = (params.get("email", [""]) if isinstance(params.get("email"), list) else params.get("email", "")).strip() | |
| q_token = (params.get("token", [""]) if isinstance(params.get("token"), list) else params.get("token", "")).strip() | |
| if action == "verify" and q_email and q_token: | |
| ok, msg = verify_email_by_link(q_email, q_token) | |
| (st.success if ok else st.error)(msg) | |
| clear_query_params() | |
| if action == "reset" and q_email and q_token: | |
| st.info("Reset your password using the form below.") | |
| left, right = st.columns([3, 2], gap="large") | |
| with left: | |
| st.markdown(f'<div class="big-title">🏦 {APP_NAME}</div>', unsafe_allow_html=True) | |
| st.markdown('<p class="subtle">Secure banking demo with link-based email verification</p>', unsafe_allow_html=True) | |
| with right: | |
| if st.session_state.get("user_id"): | |
| st.success(f"Logged in as: {st.session_state.get('user_name')}") | |
| if st.button("Log out"): | |
| logout() | |
| st.rerun() | |
| else: | |
| st.info("Not logged in") | |
| st.divider() | |
| # If user opened a reset link, show reset form (not logged in) | |
| if not st.session_state.get("user_id") and action == "reset" and q_email and q_token: | |
| st.markdown("### Reset Password") | |
| with st.form("reset_by_link_form", clear_on_submit=True): | |
| st.write(f"Email: **{norm_email(q_email)}**") | |
| new_pw = st.text_input("New password (min 6 chars)", type="password") | |
| new_pw2 = st.text_input("Confirm new password", type="password") | |
| submit = st.form_submit_button("Reset password") | |
| if submit: | |
| if new_pw != new_pw2: | |
| st.error("Passwords do not match.") | |
| else: | |
| ok, msg = reset_password_by_link(q_email, q_token, new_pw) | |
| (st.success if ok else st.error)(msg) | |
| if ok: | |
| clear_query_params() | |
| st.stop() | |
| # ---------------------------- | |
| # Auth Screens (not logged in) | |
| # ---------------------------- | |
| if not st.session_state.get("user_id"): | |
| colA, colB = st.columns(2, gap="large") | |
| with colA: | |
| st.markdown("### Log in") | |
| with st.form("login_form", clear_on_submit=False): | |
| email = st.text_input("Email", placeholder="you@example.com") | |
| password = st.text_input("Password", type="password") | |
| submit = st.form_submit_button("Log in") | |
| if submit: | |
| ok, msg, status = login(email, password) | |
| if ok: | |
| st.success(msg) | |
| st.rerun() | |
| else: | |
| st.error(msg) | |
| if status == "UNVERIFIED": | |
| st.session_state["pending_verify_email"] = norm_email(email) | |
| pending_email = st.session_state.get("pending_verify_email", "") | |
| if pending_email: | |
| st.markdown("#### Verify your email (link-based)") | |
| st.write(f"Email: **{pending_email}**") | |
| if st.button("Send verification link"): | |
| ok, msg, dev_link = send_verification_link(pending_email) | |
| (st.success if ok else st.error)(msg) | |
| if dev_link and DEV_MODE: | |
| st.markdown(f'<div class="hint">DEV_MODE link:<br><b>{dev_link}</b></div>', unsafe_allow_html=True) | |
| st.markdown("#### Forgot password (link-based)") | |
| with st.form("forgot_password_form", clear_on_submit=False): | |
| fp_email = st.text_input("Account email", placeholder="you@example.com", key="fp_email") | |
| send_reset = st.form_submit_button("Send reset link") | |
| if send_reset: | |
| ok, msg, dev_link = send_password_reset_link(fp_email) | |
| (st.success if ok else st.error)(msg) | |
| if dev_link and DEV_MODE: | |
| st.markdown(f'<div class="hint">DEV_MODE link:<br><b>{dev_link}</b></div>', unsafe_allow_html=True) | |
| with colB: | |
| st.markdown("### Open account (Register)") | |
| with st.form("register_form", clear_on_submit=True): | |
| full_name = st.text_input("Full name", placeholder="Md Ahsan Habib") | |
| email = st.text_input("Email", placeholder="you@example.com") | |
| password = st.text_input("Password (min 6 chars)", type="password") | |
| nickname = st.text_input("First account nickname", value="Checking") | |
| submit = st.form_submit_button("Create account") | |
| if submit: | |
| ok, msg = open_account(full_name, email, password, nickname) | |
| if ok: | |
| st.success(msg) | |
| st.info("Check your email and click the verification link. Then log in.") | |
| st.session_state["pending_verify_email"] = norm_email(email) | |
| else: | |
| st.error(msg) | |
| if not APP_BASE_URL: | |
| st.warning( | |
| "APP_BASE_URL is not set.\n" | |
| "Email links may not work correctly without a full URL.\n" | |
| "Set APP_BASE_URL to your deployed app URL (example: https://your-space.hf.space)." | |
| ) | |
| if not smtp_configured(): | |
| st.warning( | |
| "SMTP is not configured, so emails can't be sent.\n\n" | |
| "Set these environment variables/secrets:\n" | |
| "- SMTP_HOST\n- SMTP_PORT (usually 587)\n- SMTP_USER\n- SMTP_PASS\n- SMTP_FROM\n\n" | |
| "For local testing, set DEV_MODE=true to show links in the UI." | |
| ) | |
| # ---------------------------- | |
| # Main App (logged in) | |
| # ---------------------------- | |
| else: | |
| require_login() | |
| session = db_session() | |
| user = get_user(session) | |
| accounts = get_user_accounts(session, user.id) | |
| c1, c2, c3 = st.columns(3, gap="large") | |
| total_balance = sum(a.balance for a in accounts) | |
| with c1: | |
| st.markdown('<div class="card">', unsafe_allow_html=True) | |
| st.markdown("**Total balance**") | |
| st.markdown(f"### {money(total_balance)}") | |
| st.markdown(f'<div class="small">Across all your {APP_NAME} accounts</div>', unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="card">', unsafe_allow_html=True) | |
| st.markdown("**Accounts**") | |
| st.markdown(f"### {len(accounts)}") | |
| st.markdown('<div class="small">Checking / Savings (you can add more)</div>', unsafe_allow_html=True) | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="card">', unsafe_allow_html=True) | |
| st.markdown("**Security**") | |
| st.markdown(f"- Email verified: **{'Yes' if user.email_verified else 'No'}**") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.divider() | |
| tabs = st.tabs(["Dashboard", "Open new account", "Deposit", "Transfer", "Pay Bills", "Transactions", "Security"]) | |
| with tabs[0]: | |
| st.markdown("### Your accounts") | |
| for a in accounts: | |
| st.markdown('<div class="card">', unsafe_allow_html=True) | |
| st.markdown(f"**{a.nickname}** \nAccount: `{a.account_number}`") | |
| st.markdown(f"### {money(a.balance)}") | |
| st.markdown("</div>", unsafe_allow_html=True) | |
| st.write("") | |
| with tabs[1]: | |
| st.markdown("### Open a new account") | |
| with st.form("new_account_form"): | |
| nickname = st.text_input("Account nickname", value="Savings") | |
| submit = st.form_submit_button("Create account") | |
| if submit: | |
| try: | |
| acct_num = generate_account_number(session) | |
| acct = BankAccount(owner_id=user.id, account_number=acct_num, nickname=nickname.strip() or "Savings", balance=0.0) | |
| session.add(acct) | |
| session.commit() | |
| st.success(f"Created new account: {acct.nickname} — {acct_num}") | |
| st.rerun() | |
| except Exception as e: | |
| session.rollback() | |
| st.error(f"Error: {e}") | |
| with tabs[2]: | |
| st.markdown("### Deposit") | |
| acct_choices = {f"{a.nickname} ({a.account_number})": a.account_number for a in accounts} | |
| with st.form("deposit_form"): | |
| acct_label = st.selectbox("Select account", list(acct_choices.keys())) | |
| amount = st.number_input("Amount", min_value=0.0, value=50.0, step=10.0) | |
| note = st.text_input("Note (optional)") | |
| submit = st.form_submit_button("Deposit") | |
| if submit: | |
| ok, msg = deposit(user.id, acct_choices[acct_label], float(amount), note) | |
| (st.success if ok else st.error)(msg) | |
| if ok: | |
| st.rerun() | |
| with tabs[3]: | |
| st.markdown("### Transfer") | |
| from_choices = {f"{a.nickname} ({a.account_number}) — {money(a.balance)}": a.account_number for a in accounts} | |
| with st.form("transfer_form"): | |
| from_label = st.selectbox("From account", list(from_choices.keys())) | |
| to_acct = st.text_input("To account number") | |
| amount = st.number_input("Amount", min_value=0.0, value=25.0, step=5.0) | |
| note = st.text_input("Note (optional)") | |
| submit = st.form_submit_button("Transfer") | |
| if submit: | |
| ok, msg = transfer(user.id, from_choices[from_label], to_acct.strip(), float(amount), note) | |
| (st.success if ok else st.error)(msg) | |
| if ok: | |
| st.rerun() | |
| with tabs[4]: | |
| st.markdown("### Pay Bills") | |
| st.markdown("#### Add a payee") | |
| with st.form("add_payee_form"): | |
| name = st.text_input("Payee name") | |
| memo = st.text_input("Memo (optional)") | |
| add = st.form_submit_button("Add payee") | |
| if add: | |
| ok, msg = add_payee(user.id, name, memo) | |
| (st.success if ok else st.error)(msg) | |
| if ok: | |
| st.rerun() | |
| st.divider() | |
| payees = session.query(Payee).filter_by(owner_id=user.id).order_by(Payee.created_at.desc()).all() | |
| if payees: | |
| st.markdown("#### Make a payment") | |
| from_choices = {f"{a.nickname} ({a.account_number}) — {money(a.balance)}": a.account_number for a in accounts} | |
| payee_choices = {f"{p.name} (id:{p.id})": p.id for p in payees} | |
| with st.form("billpay_form"): | |
| from_label = st.selectbox("From account", list(from_choices.keys())) | |
| payee_label = st.selectbox("Payee", list(payee_choices.keys())) | |
| amount = st.number_input("Amount", min_value=0.0, value=40.0, step=5.0) | |
| note = st.text_input("Note (optional)") | |
| submit = st.form_submit_button("Pay bill") | |
| if submit: | |
| ok, msg = pay_bill(user.id, from_choices[from_label], payee_choices[payee_label], float(amount), note) | |
| (st.success if ok else st.error)(msg) | |
| if ok: | |
| st.rerun() | |
| else: | |
| st.info("No payees yet. Add one above.") | |
| with tabs[5]: | |
| st.markdown("### Transactions (ledger)") | |
| txs = session.query(Transaction).order_by(Transaction.created_at.desc()).limit(200).all() | |
| if not txs: | |
| st.info("No transactions yet.") | |
| else: | |
| rows = [] | |
| for t in txs: | |
| meta = {} | |
| try: | |
| meta = json.loads(t.meta or "{}") | |
| except Exception: | |
| meta = {} | |
| rows.append({ | |
| "Time (UTC)": t.created_at.strftime("%Y-%m-%d %H:%M:%S"), | |
| "Type": t.tx_type, | |
| "Amount": money(t.amount), | |
| "From": t.from_account or "-", | |
| "To": t.to_account or "-", | |
| "Details": meta.get("payee") or meta.get("note") or "", | |
| }) | |
| st.dataframe(rows, use_container_width=True) | |
| with tabs[6]: | |
| st.markdown("### Security") | |
| st.write(f"Email: **{user.email}**") | |
| st.write(f"Email verified: **{'Yes' if user.email_verified else 'No'}**") | |
| st.divider() | |
| st.markdown("#### Change password") | |
| with st.form("change_pw_form", clear_on_submit=True): | |
| current_pw = st.text_input("Current password", type="password") | |
| new_pw = st.text_input("New password (min 6 chars)", type="password") | |
| new_pw2 = st.text_input("Confirm new password", type="password") | |
| submit = st.form_submit_button("Change password") | |
| if submit: | |
| if new_pw != new_pw2: | |
| st.error("New password and confirmation do not match.") | |
| else: | |
| ok, msg = change_password(user.id, current_pw, new_pw) | |
| (st.success if ok else st.error)(msg) | |
| session.close() |