Add password visibility toggle & password reset flow
Browse files- backend/database.py +57 -1
- backend/main.py +95 -0
- frontend/forgot-password.html +105 -0
- frontend/login.html +13 -1
- frontend/register.html +12 -1
- frontend/styles.css +19 -0
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)">👁</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 ? "👀" : "👁";
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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)">👁</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 ? "👀" : "👁";
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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)">👁</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 ? "👀" : "👁";
|
| 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; }
|