shak3008 commited on
Commit
8f548b0
Β·
1 Parent(s): a61b280

feat: real email OTP via SMTP, forgot password email, Settings on mobile nav

Browse files
backend/app/api/auth.py CHANGED
@@ -26,10 +26,12 @@ _otp_store: dict[str, dict] = {}
26
  @router.post("/send-otp")
27
  def send_otp(payload: dict, db: Session = Depends(get_db)):
28
  """
29
- Generate and 'send' an OTP for email verification during signup.
30
- In production, this would send an actual email. For demo purposes,
31
- the OTP is returned in the response (and logged).
32
  """
 
 
33
  email = payload.get("email", "").strip().lower()
34
  username = payload.get("username", "")
35
  password = payload.get("password", "")
@@ -37,6 +39,10 @@ def send_otp(payload: dict, db: Session = Depends(get_db)):
37
  if not email:
38
  raise HTTPException(status_code=422, detail="Email is required.")
39
 
 
 
 
 
40
  # Check if email already registered
41
  existing = db.query(User).filter(User.email == email).first()
42
  if existing:
@@ -53,12 +59,16 @@ def send_otp(payload: dict, db: Session = Depends(get_db)):
53
  "password": password,
54
  }
55
 
56
- # In production: send email via SMTP/SendGrid/SES
57
- # For demo: return OTP directly (would be removed in prod)
58
- return {
59
- "message": f"OTP sent to {email}",
60
- "otp_preview": otp_code, # Remove in production
61
- }
 
 
 
 
62
 
63
 
64
  @router.post("/verify-otp")
@@ -153,6 +163,8 @@ def forgot_password(
153
  email_data: ForgotPasswordRequest,
154
  db: Session = Depends(get_db),
155
  ):
 
 
156
  user = db.query(User).filter(User.email == email_data.email).first()
157
 
158
  if not user:
@@ -161,7 +173,15 @@ def forgot_password(
161
  token = secrets.token_hex(16)
162
  _reset_tokens[token] = user.email
163
 
164
- return {"reset_token": token}
 
 
 
 
 
 
 
 
165
 
166
 
167
  @router.post("/reset-password")
 
26
  @router.post("/send-otp")
27
  def send_otp(payload: dict, db: Session = Depends(get_db)):
28
  """
29
+ Generate and send an OTP for email verification during signup.
30
+ If SMTP is configured, sends a real email.
31
+ If not, returns OTP in response (demo mode).
32
  """
33
+ from app.services.email_service import send_otp_email, is_email_configured
34
+
35
  email = payload.get("email", "").strip().lower()
36
  username = payload.get("username", "")
37
  password = payload.get("password", "")
 
39
  if not email:
40
  raise HTTPException(status_code=422, detail="Email is required.")
41
 
42
+ # Basic email format validation
43
+ if "@" not in email or "." not in email.split("@")[-1]:
44
+ raise HTTPException(status_code=400, detail="Invalid email address.")
45
+
46
  # Check if email already registered
47
  existing = db.query(User).filter(User.email == email).first()
48
  if existing:
 
59
  "password": password,
60
  }
61
 
62
+ # Try to send real email
63
+ email_sent = send_otp_email(email, otp_code)
64
+
65
+ response = {"message": f"Verification code sent to {email}"}
66
+
67
+ # Only show OTP preview if email sending is not configured (demo mode)
68
+ if not email_sent:
69
+ response["otp_preview"] = otp_code
70
+
71
+ return response
72
 
73
 
74
  @router.post("/verify-otp")
 
163
  email_data: ForgotPasswordRequest,
164
  db: Session = Depends(get_db),
165
  ):
166
+ from app.services.email_service import send_reset_email, is_email_configured
167
+
168
  user = db.query(User).filter(User.email == email_data.email).first()
169
 
170
  if not user:
 
173
  token = secrets.token_hex(16)
174
  _reset_tokens[token] = user.email
175
 
176
+ # Send reset email if SMTP is configured
177
+ email_sent = send_reset_email(user.email, token)
178
+
179
+ response = {"message": "Password reset instructions sent to your email."}
180
+ if not email_sent:
181
+ # Demo mode: return token directly
182
+ response["reset_token"] = token
183
+
184
+ return response
185
 
186
 
187
  @router.post("/reset-password")
backend/app/services/email_service.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email service for sending OTP verification codes.
3
+
4
+ Uses SMTP (works with Gmail, SendGrid, Mailgun, etc.)
5
+ Configure via environment variables:
6
+ SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
7
+ """
8
+
9
+ import os
10
+ import smtplib
11
+ import logging
12
+ from email.mime.text import MIMEText
13
+ from email.mime.multipart import MIMEMultipart
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
18
+ SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
19
+ SMTP_USER = os.environ.get("SMTP_USER", "")
20
+ SMTP_PASS = os.environ.get("SMTP_PASS", "")
21
+ SMTP_FROM = os.environ.get("SMTP_FROM", "") or SMTP_USER
22
+
23
+
24
+ def is_email_configured() -> bool:
25
+ """Check if SMTP credentials are configured."""
26
+ return bool(SMTP_USER and SMTP_PASS)
27
+
28
+
29
+ def send_otp_email(to_email: str, otp_code: str) -> bool:
30
+ """
31
+ Send OTP verification email.
32
+ Returns True if sent successfully, False otherwise.
33
+ """
34
+ if not is_email_configured():
35
+ logger.warning("SMTP not configured β€” OTP not sent (demo mode)")
36
+ return False
37
+
38
+ subject = "DocWeave β€” Your verification code"
39
+ html_body = f"""
40
+ <div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
41
+ <h2 style="color: #3b82f6; margin-bottom: 8px;">DocWeave</h2>
42
+ <p style="color: #666; margin-bottom: 24px;">Your email verification code is:</p>
43
+ <div style="background: #f3f4f6; border-radius: 8px; padding: 24px; text-align: center; margin-bottom: 24px;">
44
+ <span style="font-size: 32px; font-weight: 700; letter-spacing: 8px; color: #111;">{otp_code}</span>
45
+ </div>
46
+ <p style="color: #666; font-size: 14px;">This code expires in 10 minutes.</p>
47
+ <p style="color: #999; font-size: 12px; margin-top: 32px;">If you didn't request this, ignore this email.</p>
48
+ </div>
49
+ """
50
+
51
+ msg = MIMEMultipart("alternative")
52
+ msg["Subject"] = subject
53
+ msg["From"] = SMTP_FROM
54
+ msg["To"] = to_email
55
+ msg.attach(MIMEText(f"Your DocWeave verification code is: {otp_code}\n\nExpires in 10 minutes.", "plain"))
56
+ msg.attach(MIMEText(html_body, "html"))
57
+
58
+ try:
59
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
60
+ server.starttls()
61
+ server.login(SMTP_USER, SMTP_PASS)
62
+ server.sendmail(SMTP_FROM, to_email, msg.as_string())
63
+ logger.info("OTP email sent to %s", to_email)
64
+ return True
65
+ except Exception as e:
66
+ logger.error("Failed to send OTP email to %s: %s", to_email, e)
67
+ return False
68
+
69
+
70
+ def send_reset_email(to_email: str, reset_token: str) -> bool:
71
+ """
72
+ Send password reset email with a token link.
73
+ Returns True if sent successfully, False otherwise.
74
+ """
75
+ if not is_email_configured():
76
+ logger.warning("SMTP not configured β€” reset email not sent (demo mode)")
77
+ return False
78
+
79
+ # Use FRONTEND_URL env var for the reset link, fallback to localhost
80
+ frontend_url = os.environ.get("FRONTEND_URL", "http://localhost:5173")
81
+ reset_link = f"{frontend_url}/reset-password?token={reset_token}"
82
+
83
+ subject = "DocWeave β€” Reset your password"
84
+ html_body = f"""
85
+ <div style="font-family: -apple-system, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px;">
86
+ <h2 style="color: #3b82f6; margin-bottom: 8px;">DocWeave</h2>
87
+ <p style="color: #666; margin-bottom: 24px;">You requested a password reset. Click the link below:</p>
88
+ <a href="{reset_link}" style="display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 600;">
89
+ Reset Password
90
+ </a>
91
+ <p style="color: #999; font-size: 12px; margin-top: 32px;">This link expires in 1 hour. If you didn't request this, ignore this email.</p>
92
+ </div>
93
+ """
94
+
95
+ msg = MIMEMultipart("alternative")
96
+ msg["Subject"] = subject
97
+ msg["From"] = SMTP_FROM
98
+ msg["To"] = to_email
99
+ msg.attach(MIMEText(f"Reset your DocWeave password: {reset_link}", "plain"))
100
+ msg.attach(MIMEText(html_body, "html"))
101
+
102
+ try:
103
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
104
+ server.starttls()
105
+ server.login(SMTP_USER, SMTP_PASS)
106
+ server.sendmail(SMTP_FROM, to_email, msg.as_string())
107
+ logger.info("Reset email sent to %s", to_email)
108
+ return True
109
+ except Exception as e:
110
+ logger.error("Failed to send reset email to %s: %s", to_email, e)
111
+ return False
frontend/src/components/layout/Sidebar.jsx CHANGED
@@ -33,6 +33,7 @@ export function Sidebar() {
33
  { to: "/search", label: "Search", icon: "πŸ”" },
34
  { to: "/workflows", label: "Workflows", icon: "⚑", count: counts.workflows_waiting_for_review, countTone: "warning" },
35
  { to: "/activity", label: "Activity", icon: "πŸ“‹" },
 
36
  ];
37
 
38
  return (
 
33
  { to: "/search", label: "Search", icon: "πŸ”" },
34
  { to: "/workflows", label: "Workflows", icon: "⚑", count: counts.workflows_waiting_for_review, countTone: "warning" },
35
  { to: "/activity", label: "Activity", icon: "πŸ“‹" },
36
+ { to: "/settings", label: "Settings", icon: "βš™" },
37
  ];
38
 
39
  return (