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:
@@ -12,9 +12,10 @@ from contextlib import asynccontextmanager
|
|||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Request, Response
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import StreamingResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -24,6 +25,9 @@ from app.db import (
|
|||||||
build_duckdb_index, index_status,
|
build_duckdb_index, index_status,
|
||||||
queue_beauty, requeue_beauty, get_beauty_queue_status, save_beauty_assessment, get_beauty_leads,
|
queue_beauty, requeue_beauty, get_beauty_queue_status, save_beauty_assessment, get_beauty_leads,
|
||||||
save_prescreen_results,
|
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
|
from app.validator import start_validator, stop_validator, get_validator_status
|
||||||
|
|
||||||
@@ -106,7 +110,7 @@ def _start_beauty_worker():
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await init_db()
|
await init_db()
|
||||||
# Detect existing DuckDB index (built by main service); don't rebuild
|
await init_beauty_auth()
|
||||||
asyncio.create_task(build_duckdb_index())
|
asyncio.create_task(build_duckdb_index())
|
||||||
_start_beauty_worker()
|
_start_beauty_worker()
|
||||||
logger.info("BeautyLeads ready on port 7788")
|
logger.info("BeautyLeads ready on port 7788")
|
||||||
@@ -116,6 +120,121 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="BeautyLeads", lifespan=lifespan)
|
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) ──────────────────────────────────────────
|
# ── Shared read endpoints (same DB) ──────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/stats")
|
@app.get("/api/stats")
|
||||||
|
|||||||
151
app/db.py
151
app/db.py
@@ -3,6 +3,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import duckdb
|
import duckdb
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -631,3 +634,151 @@ async def get_queue_status():
|
|||||||
rate = int(os.getenv("CONCURRENCY_LIMIT", "50"))
|
rate = int(os.getenv("CONCURRENCY_LIMIT", "50"))
|
||||||
eta_seconds = (pending + running) / max(rate / 10, 1) if (pending + running) > 0 else None
|
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}
|
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()
|
||||||
|
|||||||
@@ -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==='pipeline'}" @click="tab='pipeline';loadLeads()">B2B Pipeline</button>
|
||||||
<button class="tab-btn" :class="{active:tab==='export'}" @click="tab='export'">Export</button>
|
<button class="tab-btn" :class="{active:tab==='export'}" @click="tab='export'">Export</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
@@ -628,14 +681,27 @@ function app() {
|
|||||||
valTld: '', valRescan: false,
|
valTld: '', valRescan: false,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
prescreening: false, validating: false, reassessing: false,
|
prescreening: false, validating: false, reassessing: false,
|
||||||
_loadGen: 0, // incremented on every loadDomains() call; stale responses are discarded
|
_loadGen: 0,
|
||||||
assessPopup: null, // parsed _beauty object shown in overlay; null = hidden
|
assessPopup: null,
|
||||||
exportQuality: '', exportCountry: '',
|
exportQuality: '', exportCountry: '',
|
||||||
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
|
f: {keyword:'', tld:'', prescreen_status:'live', niche:'beauty_cosmetics',
|
||||||
site_type:'ecommerce', country:'', assessed:'', alpha_only:false, no_sld:false, limit:'100', page:1},
|
site_type:'ecommerce', country:'', assessed:'', alpha_only:false, no_sld:false, limit:'100', page:1},
|
||||||
pf: {quality:'', country:'', 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() {
|
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 Promise.all([this.loadStats(), this.loadAiStatus(), this.loadValStatus()]);
|
||||||
await this.loadDomains();
|
await this.loadDomains();
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
@@ -645,6 +711,54 @@ function app() {
|
|||||||
}, 4000);
|
}, 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() {
|
async loadStats() {
|
||||||
try {
|
try {
|
||||||
const d = await fetch('/api/stats').then(r=>r.json());
|
const d = await fetch('/api/stats').then(r=>r.json());
|
||||||
|
|||||||
130
app/static/beauty/login.html
Normal file
130
app/static/beauty/login.html
Normal 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>
|
||||||
Reference in New Issue
Block a user