ronylu commited on
Commit
e0ef0e1
·
verified ·
1 Parent(s): 18f3695

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +420 -32
app.py CHANGED
@@ -1,22 +1,45 @@
1
  import os
2
  import json
3
  import secrets
4
- from datetime import datetime
 
 
 
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 ("memory")
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, "ibank.db")
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 db_session():
95
- return SessionLocal()
96
 
97
- def generate_account_number(session) -> str:
98
- # 12-digit random number, ensure uniqueness
99
- while True:
100
- acct = "".join(str(secrets.randbelow(10)) for _ in range(12))
101
- exists = session.query(BankAccount).filter_by(account_number=acct).first()
102
- if not exists:
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.query(User).get(st.session_state["user_id"])
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
- if session.query(User).filter_by(email=email.lower().strip()).first():
 
127
  return False, "Email already registered."
128
 
 
 
 
129
  user = User(
130
  full_name=full_name.strip(),
131
- email=email.lower().strip(),
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
- return True, f"Account opened! Your new account number: {acct_num}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(owner_id=user_id, account_number=account_number).with_for_update().first()
 
 
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(owner_id=user_id, account_number=from_acct_num).with_for_update().first()
 
 
208
  if not from_acct:
209
  return False, "Source account not found."
210
 
211
- to_acct = session.query(BankAccount).filter_by(account_number=to_acct_num).with_for_update().first()
 
 
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(owner_id=user_id, account_number=from_acct_num).with_for_update().first()
 
 
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="iBank", page_icon="🏦", layout="wide")
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">🏦 iBank</div>', unsafe_allow_html=True)
 
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("Open account")
346
  if submit:
347
  ok, msg = open_account(full_name, email, password, nickname)
348
  if ok:
349
  st.success(msg)
350
- st.info("Now log in with your email & password.")
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 iBank accounts</div>', unsafe_allow_html=True)
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(owner_id=user.id, account_number=acct_num, nickname=nickname.strip() or "Savings", balance=0.0)
 
 
 
 
 
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 iBank account number")
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"),