| 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_NAME = "Bank AHR" |
|
|
| |
| |
| APP_BASE_URL = os.getenv("APP_BASE_URL", "").strip() |
|
|
| |
| 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) |
|
|
| |
| DEV_MODE = os.getenv("DEV_MODE", "false").lower() in ("1", "true", "yes") |
|
|
| |
| LINK_TTL_MINUTES = int(os.getenv("LINK_TTL_MINUTES", "30")) |
| RESEND_COOLDOWN_SECONDS = int(os.getenv("RESEND_COOLDOWN_SECONDS", "60")) |
|
|
|
|
| |
| |
| |
| 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") |
|
|
|
|
| |
| |
| |
| 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_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_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) |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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;") |
|
|
| |
| 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;") |
|
|
| |
| session.commit() |
| except Exception: |
| session.rollback() |
| finally: |
| session.close() |
|
|
| ensure_schema() |
|
|
|
|
| |
| |
| |
| 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: |
| |
| 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}" |
|
|
| |
| return f"/?action={action}&email={q_email}&token={q_token}" |
|
|
| def get_query_params() -> dict: |
| |
| 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() |
|
|
|
|
| |
| |
| |
| 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}" |
|
|
|
|
| |
| |
| |
| 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() |
|
|
|
|
| |
| |
| |
| 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() |
|
|
| |
| 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() |
|
|
|
|
| |
| |
| |
| 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, |
| ) |
|
|
| |
| 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 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() |
|
|
|
|
| |
| |
| |
| 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." |
| ) |
|
|
|
|
| |
| |
| |
| 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() |