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

@@ -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>