feat: add login / user management to BeautyLeads

- db.py: beauty_users + beauty_sessions tables; PBKDF2-SHA256 password
  hashing; init_beauty_auth seeds default Admin user; full CRUD helpers
- beauty_main.py: AuthMiddleware blocks all routes except /api/auth/* and
  /login.html; auth routes: login (sets HttpOnly 30-day cookie), logout,
  /me, change-password (invalidates sessions), list/add/delete users
- login.html: standalone dark-themed sign-in page matching app palette
- index.html: auth check in init() → redirects to login.html if 401;
  header shows username + settings gear + sign-out; settings modal with
  change-password form and admin user management (add/delete users)

Default credentials: Admin / Asdpsd9012!HAP

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 13:41:08 +02:00
parent 76cb0dd41e
commit efbee1540c
4 changed files with 519 additions and 5 deletions

View File

@@ -12,9 +12,10 @@ from contextlib import asynccontextmanager
import aiosqlite
from typing import Optional
from fastapi import FastAPI, Query
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import StreamingResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from dotenv import load_dotenv
load_dotenv()
@@ -24,6 +25,9 @@ from app.db import (
build_duckdb_index, index_status,
queue_beauty, requeue_beauty, get_beauty_queue_status, save_beauty_assessment, get_beauty_leads,
save_prescreen_results,
init_beauty_auth, verify_beauty_user, create_beauty_session, validate_beauty_session,
delete_beauty_session, change_beauty_password, list_beauty_users, add_beauty_user,
delete_beauty_user,
)
from app.validator import start_validator, stop_validator, get_validator_status
@@ -106,7 +110,7 @@ def _start_beauty_worker():
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
# Detect existing DuckDB index (built by main service); don't rebuild
await init_beauty_auth()
asyncio.create_task(build_duckdb_index())
_start_beauty_worker()
logger.info("BeautyLeads ready on port 7788")
@@ -116,6 +120,121 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="BeautyLeads", lifespan=lifespan)
# ── Auth middleware ───────────────────────────────────────────────────────────
class AuthMiddleware(BaseHTTPMiddleware):
# Paths that don't require a session
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {"/login.html", "/favicon.ico"}
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path in self._EXEMPT_EXACT or any(
path.startswith(p) for p in self._EXEMPT_PREFIXES
):
return await call_next(request)
token = request.cookies.get("beauty_session")
if token:
user = await validate_beauty_session(token)
if user:
request.state.user = user
return await call_next(request)
# Unauthenticated — API calls get 401, browser requests get redirect
if path.startswith("/api/"):
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
return RedirectResponse(url="/login.html", status_code=302)
app.add_middleware(AuthMiddleware)
# ── Auth routes ───────────────────────────────────────────────────────────────
@app.post("/api/auth/login")
async def auth_login(body: dict, response: Response):
username = (body.get("username") or "").strip()
password = body.get("password") or ""
user = await verify_beauty_user(username, password)
if not user:
return JSONResponse({"detail": "Invalid credentials"}, status_code=401)
token = await create_beauty_session(user["username"])
response.set_cookie(
"beauty_session", token,
max_age=30 * 24 * 3600,
httponly=True,
samesite="lax",
path="/",
)
return {"username": user["username"], "is_admin": user["is_admin"]}
@app.post("/api/auth/logout")
async def auth_logout(request: Request, response: Response):
token = request.cookies.get("beauty_session")
if token:
await delete_beauty_session(token)
response.delete_cookie("beauty_session", path="/")
return {"ok": True}
@app.get("/api/auth/me")
async def auth_me(request: Request):
return request.state.user # middleware already validated; state always set here
@app.post("/api/auth/change-password")
async def auth_change_password(request: Request, body: dict, response: Response):
user = request.state.user
current = body.get("current_password") or ""
new_pw = body.get("new_password") or ""
if not new_pw or len(new_pw) < 8:
return JSONResponse({"detail": "Password must be at least 8 characters"}, status_code=400)
if not await verify_beauty_user(user["username"], current):
return JSONResponse({"detail": "Current password is incorrect"}, status_code=400)
await change_beauty_password(user["username"], new_pw)
# Clear cookie so the user is re-directed to login after password change
response.delete_cookie("beauty_session", path="/")
return {"ok": True}
@app.get("/api/auth/users")
async def auth_list_users(request: Request):
if not request.state.user.get("is_admin"):
return JSONResponse({"detail": "Admin only"}, status_code=403)
return await list_beauty_users()
@app.post("/api/auth/users")
async def auth_add_user(request: Request, body: dict):
if not request.state.user.get("is_admin"):
return JSONResponse({"detail": "Admin only"}, status_code=403)
username = (body.get("username") or "").strip()
password = body.get("password") or ""
is_admin = bool(body.get("is_admin", False))
if not username or not password:
return JSONResponse({"detail": "username and password required"}, status_code=400)
if len(password) < 8:
return JSONResponse({"detail": "Password must be at least 8 characters"}, status_code=400)
try:
await add_beauty_user(username, password, is_admin)
except Exception as e:
return JSONResponse({"detail": f"Could not create user: {e}"}, status_code=400)
return {"ok": True}
@app.delete("/api/auth/users/{username}")
async def auth_delete_user(username: str, request: Request):
if not request.state.user.get("is_admin"):
return JSONResponse({"detail": "Admin only"}, status_code=403)
if username == request.state.user["username"]:
return JSONResponse({"detail": "Cannot delete yourself"}, status_code=400)
await delete_beauty_user(username)
return {"ok": True}
# ── Shared read endpoints (same DB) ──────────────────────────────────────────
@app.get("/api/stats")

151
app/db.py
View File

@@ -3,6 +3,9 @@ import asyncio
import logging
import aiosqlite
import duckdb
import hashlib
import secrets
import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -631,3 +634,151 @@ async def get_queue_status():
rate = int(os.getenv("CONCURRENCY_LIMIT", "50"))
eta_seconds = (pending + running) / max(rate / 10, 1) if (pending + running) > 0 else None
return {"total": total, "pending": pending, "running": running, "done": done, "failed": failed, "eta_seconds": eta_seconds}
# ── Auth (beauty users & sessions) ───────────────────────────────────────────
def _hash_password(password: str, salt: str) -> str:
return hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), 200_000
).hex()
async def init_beauty_auth():
"""Create beauty_users / beauty_sessions tables; seed default Admin."""
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS beauty_users (
username TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS beauty_sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
)
""")
await db.execute(
"DELETE FROM beauty_sessions WHERE expires_at <= datetime('now')"
)
await db.commit()
cur = await db.execute(
"SELECT 1 FROM beauty_users WHERE username=?", ("Admin",)
)
if not await cur.fetchone():
salt = secrets.token_hex(16)
pw = _hash_password("Asdpsd9012!HAP", salt)
await db.execute(
"INSERT INTO beauty_users (username, password_hash, salt, is_admin)"
" VALUES (?,?,?,1)",
("Admin", pw, salt),
)
await db.commit()
logger.info("Auth: default Admin user created")
async def verify_beauty_user(username: str, password: str):
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT username, password_hash, salt, is_admin"
" FROM beauty_users WHERE username=?",
(username,),
)
row = await cur.fetchone()
if not row:
return None
if not secrets.compare_digest(
_hash_password(password, row["salt"]), row["password_hash"]
):
return None
return {"username": row["username"], "is_admin": bool(row["is_admin"])}
async def create_beauty_session(username: str) -> str:
token = secrets.token_urlsafe(32)
expires = (
datetime.datetime.utcnow() + datetime.timedelta(days=30)
).strftime("%Y-%m-%d %H:%M:%S")
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
await db.execute(
"INSERT INTO beauty_sessions (token, username, expires_at)"
" VALUES (?,?,?)",
(token, username, expires),
)
await db.commit()
return token
async def validate_beauty_session(token: str):
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"""SELECT s.username, u.is_admin
FROM beauty_sessions s
JOIN beauty_users u ON s.username = u.username
WHERE s.token = ? AND s.expires_at > datetime('now')""",
(token,),
)
row = await cur.fetchone()
if not row:
return None
return {"username": row["username"], "is_admin": bool(row["is_admin"])}
async def delete_beauty_session(token: str):
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
await db.execute("DELETE FROM beauty_sessions WHERE token=?", (token,))
await db.commit()
async def change_beauty_password(username: str, new_password: str):
salt = secrets.token_hex(16)
pw = _hash_password(new_password, salt)
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
await db.execute(
"UPDATE beauty_users SET password_hash=?, salt=? WHERE username=?",
(pw, salt, username),
)
await db.execute(
"DELETE FROM beauty_sessions WHERE username=?", (username,)
)
await db.commit()
async def list_beauty_users() -> list:
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT username, is_admin, created_at"
" FROM beauty_users ORDER BY created_at"
)
return [dict(r) for r in await cur.fetchall()]
async def add_beauty_user(username: str, password: str, is_admin: bool = False):
salt = secrets.token_hex(16)
pw = _hash_password(password, salt)
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
await db.execute(
"INSERT INTO beauty_users (username, password_hash, salt, is_admin)"
" VALUES (?,?,?,?)",
(username, pw, salt, int(is_admin)),
)
await db.commit()
async def delete_beauty_user(username: str):
async with aiosqlite.connect(SQLITE_PATH, timeout=30) as db:
await db.execute(
"DELETE FROM beauty_sessions WHERE username=?", (username,)
)
await db.execute("DELETE FROM beauty_users WHERE username=?", (username,))
await db.commit()

View File

@@ -105,6 +105,59 @@ textarea{width:100%;resize:vertical;font-family:monospace;font-size:12px}
<button class="tab-btn" :class="{active:tab==='pipeline'}" @click="tab='pipeline';loadLeads()">B2B Pipeline</button>
<button class="tab-btn" :class="{active:tab==='export'}" @click="tab='export'">Export</button>
</div>
<!-- Auth controls (right side) -->
<div style="margin-left:auto;display:flex;align-items:center;gap:10px" x-show="authUser">
<span style="color:var(--muted);font-size:12px" x-text="authUser?.username"></span>
<button class="btn-secondary btn-sm" @click="settingsModal=true;loadUserList()" title="Account settings"></button>
<button class="btn-secondary btn-sm" @click="logout()">Sign out</button>
</div>
</div>
<!-- Settings / User-management modal -->
<div x-show="settingsModal" x-cloak @click.self="settingsModal=false"
style="position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:1000;display:flex;align-items:center;justify-content:center;padding:16px">
<div style="background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;width:100%;max-width:480px;max-height:90vh;overflow-y:auto;position:relative">
<button @click="settingsModal=false"
style="position:absolute;top:12px;right:14px;background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer"></button>
<h3 style="font-size:15px;font-weight:700;margin-bottom:20px">Account Settings</h3>
<!-- Change password -->
<div style="margin-bottom:24px">
<h4 style="font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Change Password</h4>
<input x-model="chPw.current" type="password" placeholder="Current password" style="width:100%;margin-bottom:8px">
<input x-model="chPw.next" type="password" placeholder="New password (min 8 chars)" style="width:100%;margin-bottom:8px">
<input x-model="chPw.confirm" type="password" placeholder="Confirm new password" style="width:100%;margin-bottom:10px">
<button class="btn-primary btn-sm" @click="changePassword()">Update password</button>
</div>
<!-- User management (admin only) -->
<template x-if="authUser?.is_admin">
<div>
<h4 style="font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Users</h4>
<!-- User list -->
<div style="margin-bottom:14px">
<template x-for="u in userList" :key="u.username">
<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">
<span x-text="u.username" style="flex:1;font-size:13px"></span>
<span x-show="u.is_admin" class="chip" style="font-size:10px">admin</span>
<button x-show="u.username !== authUser.username" class="btn-danger btn-sm"
@click="deleteUser(u.username)">Delete</button>
</div>
</template>
</div>
<!-- Add user -->
<h4 style="font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:10px">Add User</h4>
<input x-model="newUser.username" type="text" placeholder="Username" style="width:100%;margin-bottom:8px">
<input x-model="newUser.password" type="password" placeholder="Password (min 8 chars)" style="width:100%;margin-bottom:8px">
<label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-bottom:10px;cursor:pointer">
<input type="checkbox" x-model="newUser.is_admin"> Admin privileges
</label>
<button class="btn-primary btn-sm" @click="addUser()">Add user</button>
</div>
</template>
</div>
</div>
<!-- Stats -->
@@ -628,14 +681,27 @@ function app() {
valTld: '', valRescan: false,
toasts: [],
prescreening: false, validating: false, reassessing: false,
_loadGen: 0, // incremented on every loadDomains() call; stale responses are discarded
assessPopup: null, // parsed _beauty object shown in overlay; null = hidden
_loadGen: 0,
assessPopup: null,
exportQuality: '', exportCountry: '',
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
site_type:'ecommerce', country:'', assessed:'', alpha_only:false, no_sld:false, limit:'100', page:1},
pf: {quality:'', country:'', limit:'100', page:1},
// Auth / settings
authUser: null,
settingsModal: false,
chPw: {current:'', next:'', confirm:''},
newUser: {username:'', password:'', is_admin:false},
userList: [],
async init() {
// Verify session — redirect to login if not authenticated
try {
const me = await fetch('/api/auth/me');
if (!me.ok) { window.location.replace('/login.html'); return; }
this.authUser = await me.json();
} catch(e) { window.location.replace('/login.html'); return; }
await Promise.all([this.loadStats(), this.loadAiStatus(), this.loadValStatus()]);
await this.loadDomains();
setInterval(async () => {
@@ -645,6 +711,54 @@ function app() {
}, 4000);
},
async logout() {
await fetch('/api/auth/logout', {method:'POST'}).catch(()=>{});
window.location.replace('/login.html');
},
async changePassword() {
if (!this.chPw.next) return this.notify('Enter a new password', 'error');
if (this.chPw.next !== this.chPw.confirm) return this.notify('Passwords do not match', 'error');
const r = await fetch('/api/auth/change-password', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({current_password: this.chPw.current, new_password: this.chPw.next}),
});
if (r.ok) {
this.notify('Password changed — please sign in again', 'info');
setTimeout(() => window.location.replace('/login.html'), 1500);
} else {
const d = await r.json().catch(()=>({}));
this.notify(d.detail || 'Failed to change password', 'error');
}
},
async loadUserList() {
try { this.userList = await fetch('/api/auth/users').then(r=>r.json()); } catch(e){}
},
async addUser() {
if (!this.newUser.username || !this.newUser.password) return this.notify('Username and password required', 'error');
const r = await fetch('/api/auth/users', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(this.newUser),
});
if (r.ok) {
this.notify('User added', 'info');
this.newUser = {username:'', password:'', is_admin:false};
await this.loadUserList();
} else {
const d = await r.json().catch(()=>({}));
this.notify(d.detail || 'Failed to add user', 'error');
}
},
async deleteUser(username) {
if (!confirm(`Delete user "${username}"?`)) return;
const r = await fetch(`/api/auth/users/${encodeURIComponent(username)}`, {method:'DELETE'});
if (r.ok) { this.notify('User deleted', 'info'); await this.loadUserList(); }
else { this.notify('Failed to delete user', 'error'); }
},
async loadStats() {
try {
const d = await fetch('/api/stats').then(r=>r.json());

View File

@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BeautyLeads — Sign In</title>
<style>
:root{
--bg:#0f0f13;--surface:#18181f;--card:#1e1e28;--border:#2a2a38;
--text:#e2e0f0;--muted:#7c7a96;--accent:#e879a0;--accent2:#c026d3;
--danger:#f43f5e;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
background:var(--bg);color:var(--text);
font-family:'Segoe UI',system-ui,sans-serif;font-size:14px;
min-height:100vh;display:flex;align-items:center;justify-content:center;
}
.wrap{width:100%;max-width:360px;padding:24px}
.logo{
text-align:center;margin-bottom:32px;
}
.logo-name{
font-size:26px;font-weight:800;
background:linear-gradient(135deg,var(--accent),var(--accent2));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
}
.logo-sub{color:var(--muted);font-size:12px;margin-top:4px}
.card{
background:var(--card);border:1px solid var(--border);border-radius:14px;
padding:28px 24px;
box-shadow:0 8px 40px rgba(0,0,0,.45);
}
h2{font-size:16px;font-weight:700;margin-bottom:20px;text-align:center}
label{display:block;font-size:11px;color:var(--muted);text-transform:uppercase;
letter-spacing:.06em;margin-bottom:5px}
input{
display:block;width:100%;background:var(--surface);color:var(--text);
border:1px solid var(--border);border-radius:8px;
padding:10px 12px;font-size:13px;outline:none;
transition:border-color .15s;
}
input:focus{border-color:var(--accent)}
.field{margin-bottom:16px}
.btn{
display:block;width:100%;padding:11px;
background:linear-gradient(135deg,var(--accent),var(--accent2));
color:#fff;border:none;border-radius:8px;
font-size:14px;font-weight:700;cursor:pointer;
transition:opacity .15s;margin-top:8px;
}
.btn:hover{opacity:.88}
.btn:disabled{opacity:.5;cursor:default}
.error{
background:rgba(244,63,94,.12);border:1px solid rgba(244,63,94,.35);
color:var(--danger);border-radius:8px;padding:9px 12px;
font-size:12px;margin-bottom:14px;display:none;
}
.error.show{display:block}
</style>
</head>
<body>
<div class="wrap">
<div class="logo">
<div class="logo-name">BeautyLeads</div>
<div class="logo-sub">Cosmetics B2B Intelligence</div>
</div>
<div class="card">
<h2>Sign In</h2>
<div class="error" id="err"></div>
<form id="loginForm">
<div class="field">
<label for="username">Username</label>
<input id="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" required>
</div>
<button class="btn" type="submit" id="submitBtn">Sign In</button>
</form>
</div>
</div>
<script>
const form = document.getElementById('loginForm');
const err = document.getElementById('err');
const btn = document.getElementById('submitBtn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.className = 'error';
btn.disabled = true;
btn.textContent = 'Signing in…';
try {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (r.ok) {
window.location.replace('/');
} else {
const d = await r.json().catch(() => ({}));
err.textContent = d.detail || 'Login failed';
err.className = 'error show';
btn.disabled = false;
btn.textContent = 'Sign In';
}
} catch (ex) {
err.textContent = 'Network error — please try again';
err.className = 'error show';
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
</script>
</body>
</html>