Britzzy commited on
Commit
eae5239
Β·
1 Parent(s): c58d954

Add password visibility toggle & password reset flow

Browse files
backend/database.py CHANGED
@@ -1,6 +1,9 @@
 
 
1
  import sqlite3
2
  import json
3
  from contextlib import contextmanager
 
4
  from backend.config import DATABASE_PATH
5
 
6
 
@@ -67,6 +70,17 @@ def init_db():
67
  UNIQUE(user_id, job_id)
68
  );
69
 
 
 
 
 
 
 
 
 
 
 
 
70
  -- Job category definitions
71
  CREATE TABLE IF NOT EXISTS job_categories (
72
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -146,6 +160,48 @@ def get_user_by_id(user_id: int) -> dict | None:
146
  return dict(row) if row else None
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  # ── API Keys ────────────────────────────────────────────────────────────────
150
 
151
  def save_api_keys(user_id: int, keys: dict) -> None:
@@ -271,7 +327,7 @@ def save_global_jobs(jobs: list[dict]) -> int:
271
 
272
  def classify_job_title(title: str, description: str = "") -> str:
273
  text = f"{title} {description}".lower()
274
- from config import JOB_CATEGORIES
275
  best_cat = "other"
276
  best_score = 0
277
  for slug, info in JOB_CATEGORIES.items():
 
1
+ import hashlib
2
+ import secrets
3
  import sqlite3
4
  import json
5
  from contextlib import contextmanager
6
+ from datetime import datetime, timedelta, timezone
7
  from backend.config import DATABASE_PATH
8
 
9
 
 
70
  UNIQUE(user_id, job_id)
71
  );
72
 
73
+ -- Password reset tokens
74
+ CREATE TABLE IF NOT EXISTS reset_tokens (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ user_id INTEGER NOT NULL,
77
+ token_hash TEXT NOT NULL,
78
+ expires_at TIMESTAMP NOT NULL,
79
+ used INTEGER DEFAULT 0,
80
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
81
+ FOREIGN KEY (user_id) REFERENCES users(id)
82
+ );
83
+
84
  -- Job category definitions
85
  CREATE TABLE IF NOT EXISTS job_categories (
86
  id INTEGER PRIMARY KEY AUTOINCREMENT,
 
160
  return dict(row) if row else None
161
 
162
 
163
+ # ── Password Reset ──────────────────────────────────────────────────────────
164
+
165
+ def create_reset_token(email: str) -> str | None:
166
+ user = get_user_by_email(email)
167
+ if not user:
168
+ return None
169
+ raw_token = secrets.token_hex(32)
170
+ token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
171
+ expires_at = (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()
172
+ with get_db() as conn:
173
+ conn.execute(
174
+ "DELETE FROM reset_tokens WHERE user_id = ?", (user["id"],)
175
+ )
176
+ conn.execute(
177
+ "INSERT INTO reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
178
+ (user["id"], token_hash, expires_at),
179
+ )
180
+ return raw_token
181
+
182
+
183
+ def get_valid_token(raw_token: str) -> dict | None:
184
+ token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
185
+ with get_db() as conn:
186
+ row = conn.execute(
187
+ """SELECT rt.*, u.email FROM reset_tokens rt
188
+ JOIN users u ON u.id = rt.user_id
189
+ WHERE rt.token_hash = ? AND rt.used = 0 AND rt.expires_at > datetime('now')""",
190
+ (token_hash,),
191
+ ).fetchone()
192
+ return dict(row) if row else None
193
+
194
+
195
+ def mark_token_used(token_id: int) -> None:
196
+ with get_db() as conn:
197
+ conn.execute("UPDATE reset_tokens SET used = 1 WHERE id = ?", (token_id,))
198
+
199
+
200
+ def update_password(user_id: int, password_hash: str) -> None:
201
+ with get_db() as conn:
202
+ conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
203
+
204
+
205
  # ── API Keys ────────────────────────────────────────────────────────────────
206
 
207
  def save_api_keys(user_id: int, keys: dict) -> None:
 
327
 
328
  def classify_job_title(title: str, description: str = "") -> str:
329
  text = f"{title} {description}".lower()
330
+ from backend.config import JOB_CATEGORIES
331
  best_cat = "other"
332
  best_score = 0
333
  for slug, info in JOB_CATEGORIES.items():
backend/main.py CHANGED
@@ -19,6 +19,7 @@ from backend.database import (
19
  get_categories, classify_job_title,
20
  link_user_job, get_user_job_link, get_user_linked_jobs, update_link_tailoring,
21
  deactivate_old_jobs,
 
22
  )
23
  from backend.auth import hash_password, verify_password, create_access_token, get_current_user
24
  from backend.llm import tailor_application as llm_tailor, make_cv_from_scratch
@@ -75,6 +76,12 @@ class RegisterRequest(BaseModel):
75
  class LoginRequest(BaseModel):
76
  email: str; password: str
77
 
 
 
 
 
 
 
78
  class ScrapeRequest(BaseModel):
79
  title: str = ""; boards: list[str] = []
80
 
@@ -113,6 +120,30 @@ def me(current_user: dict = Depends(get_current_user)):
113
  return get_user_by_id(current_user["user_id"])
114
 
115
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  # ── CV Routes ───────────────────────────────────────────────────────────────
117
 
118
  @app.get("/api/cv")
@@ -269,6 +300,70 @@ def scrape_cron(req: ScrapeRequest, token: str = Query("")):
269
  threading.Thread(target=run_nightly_scrape, daemon=True).start()
270
  return {"status": "started", "message": "Scraping in background"}
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  @app.get("/api/jobs")
274
  def list_global_jobs(
 
19
  get_categories, classify_job_title,
20
  link_user_job, get_user_job_link, get_user_linked_jobs, update_link_tailoring,
21
  deactivate_old_jobs,
22
+ create_reset_token, get_valid_token, mark_token_used, update_password,
23
  )
24
  from backend.auth import hash_password, verify_password, create_access_token, get_current_user
25
  from backend.llm import tailor_application as llm_tailor, make_cv_from_scratch
 
76
  class LoginRequest(BaseModel):
77
  email: str; password: str
78
 
79
+ class ForgotPasswordRequest(BaseModel):
80
+ email: str
81
+
82
+ class ResetPasswordRequest(BaseModel):
83
+ token: str; password: str
84
+
85
  class ScrapeRequest(BaseModel):
86
  title: str = ""; boards: list[str] = []
87
 
 
120
  return get_user_by_id(current_user["user_id"])
121
 
122
 
123
+ @app.post("/api/auth/forgot-password")
124
+ def forgot_password(req: ForgotPasswordRequest):
125
+ if not req.email:
126
+ raise HTTPException(400, "Email is required")
127
+ raw_token = create_reset_token(req.email)
128
+ if not raw_token:
129
+ return {"message": "If that email is registered, a reset token has been generated"}
130
+ return {"token": raw_token, "message": "Use this token to reset your password (expires in 15 minutes)"}
131
+
132
+
133
+ @app.post("/api/auth/reset-password")
134
+ def reset_password(req: ResetPasswordRequest):
135
+ if not req.token or not req.password:
136
+ raise HTTPException(400, "Token and password are required")
137
+ if len(req.password) < 6:
138
+ raise HTTPException(400, "Password must be at least 6 characters")
139
+ entry = get_valid_token(req.token)
140
+ if not entry:
141
+ raise HTTPException(400, "Invalid or expired reset token")
142
+ mark_token_used(entry["id"])
143
+ update_password(entry["user_id"], hash_password(req.password))
144
+ return {"message": "Password reset successfully"}
145
+
146
+
147
  # ── CV Routes ───────────────────────────────────────────────────────────────
148
 
149
  @app.get("/api/cv")
 
300
  threading.Thread(target=run_nightly_scrape, daemon=True).start()
301
  return {"status": "started", "message": "Scraping in background"}
302
 
303
+ @app.get("/api/check-network")
304
+ def check_network():
305
+ import requests as _req
306
+ results = {}
307
+ for url in ["https://google.com", "https://www.myjobmag.com", "https://api.github.com"]:
308
+ try:
309
+ r = _req.get(url, timeout=8)
310
+ results[url] = f"OK ({r.status_code})"
311
+ except Exception as e:
312
+ results[url] = f"FAIL: {type(e).__name__}"
313
+ return results
314
+
315
+ @app.get("/api/scrape/test")
316
+ def scrape_test(board: str = ""):
317
+ from backend.scrapers.nigeria import NigerianJobScraper
318
+ from backend.database import save_global_jobs, get_global_stats
319
+ s = NigerianJobScraper()
320
+
321
+ if board:
322
+ import io, contextlib, sys, requests as _req
323
+ url = f"https://www.{board}.com/search?q=Data+Analyst"
324
+ if board == "hotnigerianjobs":
325
+ url = "https://www.hotnigerianjobs.com/?s=Data+Analyst"
326
+ elif board == "myjobmag":
327
+ url = "https://www.myjobmag.com/search?q=Data+Analyst"
328
+ elif board == "ngcareers":
329
+ url = "https://ngcareers.com/jobs?q=Data+Analyst"
330
+ elif board == "jobzilla":
331
+ url = "https://www.jobzilla.ng/jobs?q=Data+Analyst"
332
+ elif board == "jobgurus":
333
+ url = "https://www.jobgurus.com.ng/?s=Data+Analyst"
334
+ r = _req.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
335
+ method = getattr(s, f"scrape_{board}", None)
336
+ if not method:
337
+ return {"error": f"no scraper named {board}"}
338
+ buf = io.StringIO()
339
+ with contextlib.redirect_stdout(buf):
340
+ with contextlib.redirect_stderr(buf):
341
+ try:
342
+ jobs = method("Data Analyst")
343
+ except Exception as e:
344
+ return {"board": board, "error": str(e), "logs": buf.getvalue()[:2000]}
345
+ return {
346
+ "board": board,
347
+ "count": len(jobs or []),
348
+ "first": (jobs[0] if jobs else None),
349
+ "logs": buf.getvalue()[:2000],
350
+ "direct_fetch": {"status": r.status_code, "length": len(r.text)},
351
+ }
352
+
353
+ all_jobs = []
354
+ results = {}
355
+ for name in dir(s):
356
+ if name.startswith("scrape_"):
357
+ board_name = name.replace("scrape_", "")
358
+ try:
359
+ jobs = getattr(s, name)("Data Analyst")
360
+ results[board_name] = len(jobs or [])
361
+ if jobs: all_jobs.extend(jobs)
362
+ except Exception as e:
363
+ results[board_name] = f"err: {e}"
364
+ saved = save_global_jobs(all_jobs)
365
+ return {"results": results, "total_scraped": len(all_jobs), "total_saved": saved, "stats": get_global_stats()}
366
+
367
 
368
  @app.get("/api/jobs")
369
  def list_global_jobs(
frontend/forgot-password.html ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Joblin - Reset Password</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <div class="auth-page">
11
+ <div class="auth-card" id="step1">
12
+ <a href="index.html" class="auth-brand">
13
+ <div class="auth-brand-icon">J</div>
14
+ <h1>Job<span>lin</span></h1>
15
+ </a>
16
+ <h1>Reset Password</h1>
17
+ <p class="subtitle">Enter your email to get a reset token</p>
18
+ <form id="tokenForm">
19
+ <div class="form-group">
20
+ <label>Email</label>
21
+ <input type="email" id="email" placeholder="you@example.com" required autocomplete="email">
22
+ </div>
23
+ <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="tokenBtn">Get Reset Token</button>
24
+ </form>
25
+ <p class="footer-link"><a href="login.html">Back to sign in</a></p>
26
+ </div>
27
+
28
+ <div class="auth-card" id="step2" style="display:none">
29
+ <a href="index.html" class="auth-brand">
30
+ <div class="auth-brand-icon">J</div>
31
+ <h1>Job<span>lin</span></h1>
32
+ </a>
33
+ <h1>Reset Password</h1>
34
+ <p class="subtitle">Use the token below to set a new password</p>
35
+ <div class="reset-token-box" id="tokenDisplay"></div>
36
+ <form id="resetForm">
37
+ <div class="form-group">
38
+ <label>Reset Token</label>
39
+ <input type="text" id="resetToken" placeholder="Paste or copy the token above" required>
40
+ </div>
41
+ <div class="form-group">
42
+ <label>New Password</label>
43
+ <div class="pwd-wrapper">
44
+ <input type="password" id="newPassword" placeholder="At least 6 characters" required minlength="6" autocomplete="new-password">
45
+ <span class="pwd-toggle" onclick="togglePwd('newPassword',this)">&#128065;</span>
46
+ </div>
47
+ </div>
48
+ <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="resetBtn">Reset Password</button>
49
+ </form>
50
+ <p class="footer-link"><a href="login.html">Back to sign in</a></p>
51
+ </div>
52
+ </div>
53
+ <script>
54
+ function togglePwd(id, el) {
55
+ const inp = document.getElementById(id);
56
+ const isPwd = inp.type === "password";
57
+ inp.type = isPwd ? "text" : "password";
58
+ el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
59
+ }
60
+ </script>
61
+ <script src="app.js"></script>
62
+ <script>
63
+ const API_BASE = window.location.origin;
64
+
65
+ document.getElementById("tokenForm").addEventListener("submit", async (e) => {
66
+ e.preventDefault();
67
+ const btn = document.getElementById("tokenBtn");
68
+ btn.disabled = true;
69
+ btn.innerHTML = '<span class="spinner"></span> Sending...';
70
+ try {
71
+ const data = await apiFetch("POST", "/api/auth/forgot-password", {
72
+ email: document.getElementById("email").value,
73
+ });
74
+ document.getElementById("tokenDisplay").textContent = data.token || "Token generated (check console)";
75
+ if (!data.token) console.log("Reset token:", data);
76
+ document.getElementById("step1").style.display = "none";
77
+ document.getElementById("step2").style.display = "block";
78
+ } catch (err) {
79
+ showToast(err.message, "error");
80
+ btn.disabled = false;
81
+ btn.textContent = "Get Reset Token";
82
+ }
83
+ });
84
+
85
+ document.getElementById("resetForm").addEventListener("submit", async (e) => {
86
+ e.preventDefault();
87
+ const btn = document.getElementById("resetBtn");
88
+ btn.disabled = true;
89
+ btn.innerHTML = '<span class="spinner"></span> Resetting...';
90
+ try {
91
+ const data = await apiFetch("POST", "/api/auth/reset-password", {
92
+ token: document.getElementById("resetToken").value,
93
+ password: document.getElementById("newPassword").value,
94
+ });
95
+ showToast("Password reset successfully! Redirecting to login...");
96
+ setTimeout(() => { window.location.href = "login.html"; }, 1500);
97
+ } catch (err) {
98
+ showToast(err.message, "error");
99
+ btn.disabled = false;
100
+ btn.textContent = "Reset Password";
101
+ }
102
+ });
103
+ </script>
104
+ </body>
105
+ </html>
frontend/login.html CHANGED
@@ -22,13 +22,25 @@
22
  </div>
23
  <div class="form-group">
24
  <label>Password</label>
25
- <input type="password" id="password" placeholder="Enter your password" required autocomplete="current-password">
 
 
 
26
  </div>
27
  <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="loginBtn">Sign In</button>
28
  </form>
 
29
  <p class="footer-link">Don't have an account? <a href="register.html">Create one</a></p>
30
  </div>
31
  </div>
 
 
 
 
 
 
 
 
32
  <script src="app.js"></script>
33
  <script>
34
  const btn = document.getElementById("loginBtn");
 
22
  </div>
23
  <div class="form-group">
24
  <label>Password</label>
25
+ <div class="pwd-wrapper">
26
+ <input type="password" id="password" placeholder="Enter your password" required autocomplete="current-password">
27
+ <span class="pwd-toggle" onclick="togglePwd('password',this)">&#128065;</span>
28
+ </div>
29
  </div>
30
  <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="loginBtn">Sign In</button>
31
  </form>
32
+ <p class="footer-link" style="margin-bottom:var(--space-2)"><a href="forgot-password.html">Forgot password?</a></p>
33
  <p class="footer-link">Don't have an account? <a href="register.html">Create one</a></p>
34
  </div>
35
  </div>
36
+ <script>
37
+ function togglePwd(id, el) {
38
+ const inp = document.getElementById(id);
39
+ const isPwd = inp.type === "password";
40
+ inp.type = isPwd ? "text" : "password";
41
+ el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
42
+ }
43
+ </script>
44
  <script src="app.js"></script>
45
  <script>
46
  const btn = document.getElementById("loginBtn");
frontend/register.html CHANGED
@@ -26,13 +26,24 @@
26
  </div>
27
  <div class="form-group">
28
  <label>Password</label>
29
- <input type="password" id="password" placeholder="At least 6 characters" required minlength="6" autocomplete="new-password">
 
 
 
30
  </div>
31
  <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="registerBtn">Create Account</button>
32
  </form>
33
  <p class="footer-link">Already have an account? <a href="login.html">Sign in</a></p>
34
  </div>
35
  </div>
 
 
 
 
 
 
 
 
36
  <script src="app.js"></script>
37
  <script>
38
  const btn = document.getElementById("registerBtn");
 
26
  </div>
27
  <div class="form-group">
28
  <label>Password</label>
29
+ <div class="pwd-wrapper">
30
+ <input type="password" id="password" placeholder="At least 6 characters" required minlength="6" autocomplete="new-password">
31
+ <span class="pwd-toggle" onclick="togglePwd('password',this)">&#128065;</span>
32
+ </div>
33
  </div>
34
  <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="registerBtn">Create Account</button>
35
  </form>
36
  <p class="footer-link">Already have an account? <a href="login.html">Sign in</a></p>
37
  </div>
38
  </div>
39
+ <script>
40
+ function togglePwd(id, el) {
41
+ const inp = document.getElementById(id);
42
+ const isPwd = inp.type === "password";
43
+ inp.type = isPwd ? "text" : "password";
44
+ el.innerHTML = isPwd ? "&#128064;" : "&#128065;";
45
+ }
46
+ </script>
47
  <script src="app.js"></script>
48
  <script>
49
  const btn = document.getElementById("registerBtn");
frontend/styles.css CHANGED
@@ -646,6 +646,25 @@ label { display: block; font-size: var(--text-sm); font-weight: 500; color: var(
646
 
647
  .auth-card .footer-link { text-align: center; font-size: var(--text-sm); color: var(--text-muted); margin-top: var(--space-5); }
648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  /* ── Search ── */
650
  .search-section { display: flex; gap: var(--space-2); }
651
  .search-section input { flex: 1; }
 
646
 
647
  .auth-card .footer-link { text-align: center; font-size: var(--text-sm); color: var(--text-muted); margin-top: var(--space-5); }
648
 
649
+ /* ── Password visibility toggle ── */
650
+ .pwd-wrapper { position: relative; }
651
+ .pwd-wrapper input { padding-right: 44px; width: 100%; }
652
+ .pwd-toggle {
653
+ position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
654
+ cursor: pointer; font-size: 20px; line-height: 1; user-select: none;
655
+ color: var(--text-muted); transition: color .15s; padding: 4px;
656
+ }
657
+ .pwd-toggle:hover { color: var(--text-default); }
658
+
659
+ /* ── Reset token display ── */
660
+ .reset-token-box {
661
+ background: var(--bg-surface-hover); border: 1px dashed var(--color-primary-300);
662
+ border-radius: var(--radius-lg); padding: var(--space-4);
663
+ font-family: var(--font-mono, monospace); font-size: var(--text-xs);
664
+ word-break: break-all; text-align: center; margin-bottom: var(--space-5);
665
+ color: var(--text-default); user-select: all;
666
+ }
667
+
668
  /* ── Search ── */
669
  .search-section { display: flex; gap: var(--space-2); }
670
  .search-section input { flex: 1; }