- Email verification: send link on register via SMTP2GO (noreply@pedshub.com), verify endpoint, beautiful HTML email - Unverified users see yellow banner in app with resend button; also shown on settings page - User settings page (/settings): personal Nextcloud credentials (override admin-wide config) - Admin and per-user Nextcloud config merged (user settings take priority) - Groq PlayAI TTS: 20 English voices + 2 Arabic voices via groq-playai-tts / groq-playai-tts-arabic - Added groq-playai-tts and groq-playai-tts-arabic to litellm config - Favicon SVG (purple play button) across all pages - Settings link in user dropdown menu - verify-ok.html and verify-fail.html result pages - DB migration: added email_verified column, verification_tokens and user_settings tables Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
8.3 KiB
HTML
198 lines
8.3 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Speechify — Admin</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--bg:#0d0d0d;--bg2:#161616;--bg3:#212121;--bg4:#2a2a2a;
|
|
--accent:#7c3aed;--accent-h:#9d6fff;
|
|
--text:#e8e8e8;--text-m:#888;--border:#232323;--danger:#ef4444;
|
|
}
|
|
body { font-family:'Inter',sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
|
|
header {
|
|
height:54px; background:var(--bg2); border-bottom:1px solid var(--border);
|
|
display:flex; align-items:center; justify-content:space-between; padding:0 24px;
|
|
}
|
|
.logo { display:flex; align-items:center; gap:10px; font-size:17px; font-weight:700; }
|
|
.header-right { display:flex; align-items:center; gap:12px; }
|
|
.back-btn, .logout-btn {
|
|
background:var(--bg3); border:1px solid var(--border); color:var(--text-m);
|
|
border-radius:7px; padding:6px 14px; font-size:13px; font-family:inherit;
|
|
cursor:pointer; text-decoration:none; display:inline-block; transition:all .15s;
|
|
}
|
|
.back-btn:hover, .logout-btn:hover { border-color:var(--accent); color:var(--text); }
|
|
.container { max-width:900px; margin:0 auto; padding:32px 24px; }
|
|
h2 { font-size:18px; font-weight:600; margin-bottom:20px; }
|
|
h3 { font-size:14px; font-weight:600; color:var(--text-m); text-transform:uppercase;
|
|
letter-spacing:.5px; margin-bottom:14px; }
|
|
.card { background:var(--bg2); border:1px solid var(--border); border-radius:12px;
|
|
padding:24px; margin-bottom:24px; }
|
|
table { width:100%; border-collapse:collapse; font-size:14px; }
|
|
th { text-align:left; padding:8px 12px; font-size:11px; font-weight:600; color:var(--text-m);
|
|
text-transform:uppercase; letter-spacing:.5px; border-bottom:1px solid var(--border); }
|
|
td { padding:10px 12px; border-bottom:1px solid var(--border); }
|
|
tr:last-child td { border-bottom:none; }
|
|
.badge {
|
|
display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600;
|
|
}
|
|
.badge-admin { background:rgba(124,58,237,.2); color:#a78bfa; }
|
|
.badge-user { background:rgba(255,255,255,.07); color:var(--text-m); }
|
|
.badge-on { background:rgba(34,197,94,.15); color:#4ade80; }
|
|
.badge-off { background:rgba(239,68,68,.15); color:#f87171; }
|
|
.btn-sm {
|
|
padding:4px 10px; border-radius:6px; font-size:12px; font-family:inherit;
|
|
cursor:pointer; border:1px solid var(--border); background:var(--bg3); color:var(--text-m);
|
|
transition:all .15s;
|
|
}
|
|
.btn-sm:hover { border-color:var(--accent); color:var(--text); }
|
|
.btn-danger { border-color:var(--danger); color:var(--danger); }
|
|
.btn-danger:hover { background:rgba(239,68,68,.1); }
|
|
.form-row { display:flex; flex-direction:column; gap:6px; margin-bottom:16px; }
|
|
label { font-size:12px; font-weight:600; color:var(--text-m); text-transform:uppercase; letter-spacing:.5px; }
|
|
input, select {
|
|
background:var(--bg3); border:1px solid var(--border); border-radius:8px;
|
|
color:var(--text); font-family:inherit; font-size:14px; padding:9px 12px; outline:none; width:100%;
|
|
}
|
|
input:focus, select:focus { border-color:var(--accent); }
|
|
.btn-primary {
|
|
background:var(--accent); color:#fff; border:none; border-radius:8px;
|
|
padding:10px 20px; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer;
|
|
}
|
|
.btn-primary:hover { background:var(--accent-h); }
|
|
.msg { font-size:13px; color:#4ade80; margin-top:10px; min-height:18px; }
|
|
.sections { display:grid; grid-template-columns:1fr 1fr; gap:24px; }
|
|
@media(max-width:640px){ .sections { grid-template-columns:1fr; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="logo">
|
|
<svg width="28" height="28" viewBox="0 0 30 30" fill="none">
|
|
<rect width="30" height="30" rx="9" fill="#7c3aed"/>
|
|
<polygon points="11,8 23,15 11,22" fill="white"/>
|
|
</svg>
|
|
Admin Panel
|
|
</div>
|
|
<div class="header-right">
|
|
<a href="/" class="back-btn">← Back to App</a>
|
|
<button class="logout-btn" onclick="logout()">Sign Out</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="container">
|
|
|
|
<div class="sections">
|
|
<!-- Nextcloud Settings -->
|
|
<div class="card">
|
|
<h3>Nextcloud Integration</h3>
|
|
<div class="form-row"><label>Nextcloud URL</label>
|
|
<input id="nc-url" type="url" placeholder="https://cloud.example.com"/></div>
|
|
<div class="form-row"><label>Username</label>
|
|
<input id="nc-user" type="text"/></div>
|
|
<div class="form-row"><label>App Password</label>
|
|
<input id="nc-pass" type="password"/></div>
|
|
<button class="btn-primary" onclick="saveSettings()">Save</button>
|
|
<div class="msg" id="nc-msg"></div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="card">
|
|
<h3>System</h3>
|
|
<div id="stats" style="font-size:14px;line-height:2;color:var(--text-m);">Loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users -->
|
|
<div class="card">
|
|
<h3>Users</h3>
|
|
<table id="users-table">
|
|
<thead><tr><th>ID</th><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead>
|
|
<tbody id="users-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
async function init() {
|
|
const me = await fetch('/api/auth/me').then(r => r.json()).catch(() => null);
|
|
if (!me || me.role !== 'admin') { window.location.href = '/'; return; }
|
|
|
|
loadUsers();
|
|
loadSettings();
|
|
document.getElementById('stats').innerHTML = `Logged in as <strong>${me.username}</strong> (admin)`;
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const users = await fetch('/api/admin/users').then(r => r.json());
|
|
const tbody = document.getElementById('users-body');
|
|
tbody.innerHTML = '';
|
|
for (const u of users) {
|
|
tbody.innerHTML += `
|
|
<tr>
|
|
<td>${u.id}</td>
|
|
<td><strong>${u.username}</strong></td>
|
|
<td>${u.email}</td>
|
|
<td><span class="badge badge-${u.role}">${u.role}</span></td>
|
|
<td><span class="badge ${u.is_active ? 'badge-on' : 'badge-off'}">${u.is_active ? 'Active' : 'Disabled'}</span></td>
|
|
<td>${u.created_at.split('T')[0]}</td>
|
|
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
|
<button class="btn-sm" onclick="toggleRole(${u.id},'${u.role}')">${u.role === 'admin' ? '→ user' : '→ admin'}</button>
|
|
<button class="btn-sm" onclick="toggleActive(${u.id},${u.is_active})">${u.is_active ? 'Disable' : 'Enable'}</button>
|
|
<button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${u.username}')">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}
|
|
}
|
|
|
|
async function toggleRole(id, current) {
|
|
const newRole = current === 'admin' ? 'user' : 'admin';
|
|
await fetch(`/api/admin/users/${id}`, { method:'PATCH', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({role: newRole}) });
|
|
loadUsers();
|
|
}
|
|
async function toggleActive(id, current) {
|
|
await fetch(`/api/admin/users/${id}`, { method:'PATCH', headers:{'Content-Type':'application/json'},
|
|
body: JSON.stringify({is_active: !current}) });
|
|
loadUsers();
|
|
}
|
|
async function deleteUser(id, name) {
|
|
if (!confirm(`Delete user "${name}"?`)) return;
|
|
await fetch(`/api/admin/users/${id}`, { method:'DELETE' });
|
|
loadUsers();
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const s = await fetch('/api/admin/settings').then(r => r.json());
|
|
document.getElementById('nc-url').value = s.nextcloud_url || '';
|
|
document.getElementById('nc-user').value = s.nextcloud_username || '';
|
|
}
|
|
|
|
async function saveSettings() {
|
|
const msg = document.getElementById('nc-msg');
|
|
await fetch('/api/admin/settings', {
|
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
|
body: JSON.stringify({
|
|
nextcloud_url: document.getElementById('nc-url').value,
|
|
nextcloud_username: document.getElementById('nc-user').value,
|
|
nextcloud_password: document.getElementById('nc-pass').value || undefined,
|
|
}),
|
|
});
|
|
msg.textContent = '✓ Saved';
|
|
setTimeout(() => msg.textContent = '', 3000);
|
|
}
|
|
|
|
async function logout() {
|
|
await fetch('/api/auth/logout', { method:'POST' });
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|