767 lines
34 KiB
HTML
767 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Bill Tracker — Admin</title>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
:root {
|
|
--bg: #0d1526;
|
|
--surface: #162236;
|
|
--surface-2: #1c2d47;
|
|
--surface-3: #223558;
|
|
--border: rgba(255,255,255,0.07);
|
|
--border-strong: rgba(255,255,255,0.14);
|
|
--text: #e2e8f0;
|
|
--text-muted: #8faab8;
|
|
--text-faint: #506070;
|
|
--primary: #6366f1;
|
|
--primary-hover: #4f46e5;
|
|
--primary-light: rgba(99,102,241,0.18);
|
|
--danger: #f43f5e;
|
|
--danger-light: rgba(244,63,94,0.15);
|
|
--success: #22d3a5;
|
|
--success-light: rgba(34,211,165,0.15);
|
|
--warning: #fb923c;
|
|
--warning-light: rgba(251,146,60,0.15);
|
|
--radius: 6px;
|
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
}
|
|
body { font-family: var(--font); font-size: 14px; background: var(--bg); color: var(--text); min-height: 100vh; }
|
|
header { background: #0a1120; color: var(--text); padding: 0 24px; height: 52px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); }
|
|
.logo { display: flex; align-items: center; gap: 8px; }
|
|
.logo-icon { width: 28px; height: 28px; background: var(--primary); border-radius: var(--radius); display: flex; align-items: center; justify-content: center; font-weight: 800; color: white; font-size: 13px; box-shadow: 0 0 10px rgba(99,102,241,0.35); }
|
|
.logo-text { font-weight: 700; font-size: 15px; color: var(--text); }
|
|
.admin-badge { background: var(--warning-light); color: var(--warning); border: 1px solid rgba(251,146,60,0.25); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; padding: 2px 7px; border-radius: 20px; margin-left: 4px; }
|
|
header .spacer { flex: 1; }
|
|
#admin-name { color: var(--text-faint); font-size: 13px; margin-right: 8px; }
|
|
.btn-logout { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border-strong); padding: 6px 12px; border-radius: var(--radius); font-size: 13px; cursor: pointer; transition: background .15s, color .15s; }
|
|
.btn-logout:hover { background: var(--surface-3); color: var(--text); }
|
|
main { max-width: 700px; margin: 32px auto; padding: 0 20px; }
|
|
|
|
/* ── Onboarding wizard ── */
|
|
#onboarding { display: none; }
|
|
.wizard-step { display: none; }
|
|
.wizard-step.active { display: block; }
|
|
.wizard-card { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 10px; padding: 32px; box-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border); }
|
|
.wizard-icon { font-size: 32px; margin-bottom: 16px; }
|
|
.wizard-card h2 { font-size: 20px; font-weight: 700; margin-bottom: 8px; color: var(--text); }
|
|
.wizard-card .sub { color: var(--text-muted); font-size: 14px; margin-bottom: 24px; line-height: 1.6; }
|
|
.capability-list { list-style: none; margin: 0 0 24px; }
|
|
.capability-list li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
|
.capability-list li:last-child { border-bottom: none; }
|
|
.cap-icon { font-size: 15px; flex-shrink: 0; margin-top: 1px; }
|
|
.cap-can { color: var(--success); }
|
|
.cap-cant { color: var(--danger); }
|
|
.cap-text { line-height: 1.5; }
|
|
.cap-text strong { display: block; color: var(--text); }
|
|
.cap-text span { color: var(--text-muted); font-size: 13px; }
|
|
|
|
/* ── Normal admin panel ── */
|
|
#panel { display: none; }
|
|
.notice { background: var(--warning-light); border: 1px solid rgba(251,146,60,0.25); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px; }
|
|
.notice strong { color: var(--warning); display: block; margin-bottom: 4px; font-size: 13px; }
|
|
.notice ul { margin: 8px 0 0 20px; font-size: 13px; color: var(--text-muted); }
|
|
.notice li { margin-bottom: 2px; }
|
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border); }
|
|
.card h2 { font-size: 15px; font-weight: 700; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th { text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); padding: 6px 10px; border-bottom: 1px solid var(--border-strong); background: var(--surface-2); }
|
|
td { padding: 10px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text); }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: var(--surface-2); }
|
|
.role-badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
|
.role-admin { background: var(--warning-light); color: var(--warning); }
|
|
.role-user { background: var(--primary-light); color: var(--primary); }
|
|
|
|
/* ── Shared form/button styles ── */
|
|
.btn { display: inline-flex; align-items: center; gap: 4px; padding: 8px 16px; border: none; border-radius: var(--radius); font-size: 13px; font-family: var(--font); font-weight: 600; cursor: pointer; transition: background .15s, box-shadow .15s; }
|
|
.btn-primary { background: var(--primary); color: white; }
|
|
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
|
|
.btn-ghost { background: transparent; color: var(--text-muted); border: 1px solid var(--border-strong); }
|
|
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
|
|
.btn-danger { background: var(--danger); color: white; }
|
|
.btn-danger:hover:not(:disabled) { background: #e11d48; }
|
|
.btn-sm { padding: 4px 9px; font-size: 12px; }
|
|
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
|
.btn-full { width: 100%; justify-content: center; }
|
|
.add-form { display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end; }
|
|
.form-group { display: flex; flex-direction: column; gap: 5px; }
|
|
.form-group label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
|
|
input[type="text"], input[type="password"], input[type="email"], input[type="number"], select { padding: 8px 10px; border: 1px solid var(--border-strong); border-radius: var(--radius); font-size: 13px; font-family: var(--font); color: var(--text); background: var(--surface-2); width: 100%; transition: border-color .15s, box-shadow .15s; }
|
|
input::placeholder, select option[disabled] { color: var(--text-faint); }
|
|
select option { background: var(--surface-2); color: var(--text); }
|
|
input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--primary); cursor: pointer; }
|
|
input:focus, select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); }
|
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px; }
|
|
.err { color: var(--danger); font-size: 12px; margin-top: 6px; min-height: 16px; }
|
|
.ok { color: var(--success); font-size: 12px; margin-top: 6px; min-height: 16px; }
|
|
.reset-form { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
|
.reset-form input { min-width: 120px; padding: 4px 8px; font-size: 12px; }
|
|
.muted { color: var(--text-muted); font-size: 12px; }
|
|
code { background: var(--surface-3); color: #38bdf8; padding: 1px 5px; border-radius: 3px; font-size: 12px; font-family: 'SF Mono', monospace; }
|
|
#panel-status { font-size: 13px; margin-top: 4px; min-height: 18px; }
|
|
.step-dots { display: flex; gap: 6px; justify-content: center; margin-bottom: 24px; }
|
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--surface-3); transition: background .2s; }
|
|
.dot.active { background: var(--primary); }
|
|
|
|
/* Email settings form */
|
|
.smtp-form { display: grid; grid-template-columns: 220px 1fr; gap: 10px 16px; align-items: center; }
|
|
.smtp-form .row-label { font-size: 13px; font-weight: 500; color: var(--text); }
|
|
.smtp-form .row-sub { font-size: 11px; color: var(--text-faint); margin-top:1px; }
|
|
.smtp-form .row-ctrl { display: flex; align-items: center; gap: 8px; }
|
|
.smtp-divider { grid-column: 1/-1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
|
.pw-wrap { position: relative; flex: 1; }
|
|
.pw-wrap input { padding-right: 36px; }
|
|
.pw-toggle { position:absolute; right:8px; top:50%; transform:translateY(-50%); background:none; border:none; cursor:pointer; color:var(--text-faint); font-size:16px; line-height:1; }
|
|
.pw-toggle:hover { color: var(--text); }
|
|
.enabled-badge { display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:20px; font-size:11px; font-weight:700; }
|
|
.badge-on { background:var(--success-light); color:var(--success); }
|
|
.badge-off { background:var(--surface-2); color:var(--text-muted); }
|
|
.test-row { grid-column:1/-1; display:flex; gap:8px; align-items:center; margin-top:4px; }
|
|
.notif-actions { grid-column:1/-1; display:flex; gap:8px; justify-content:flex-end; margin-top:8px; }
|
|
|
|
/* ── Scrollbar ── */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header>
|
|
<div class="logo">
|
|
<div class="logo-icon">$</div>
|
|
<span class="logo-text">BillTracker</span>
|
|
<span class="admin-badge">Admin</span>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<span id="admin-name"></span>
|
|
<button class="btn-logout" id="logout-btn">Sign Out</button>
|
|
</header>
|
|
|
|
<!-- ── Onboarding wizard (shown when no users exist yet) ───────────────── -->
|
|
<div id="onboarding">
|
|
<main>
|
|
<div class="step-dots">
|
|
<div class="dot active" id="dot-1"></div>
|
|
<div class="dot" id="dot-2"></div>
|
|
</div>
|
|
|
|
<!-- Step 1: Explain the admin account -->
|
|
<div class="wizard-step active" id="step-1">
|
|
<div class="wizard-card">
|
|
<div class="wizard-icon">🔒</div>
|
|
<h2>Welcome to Bill Tracker</h2>
|
|
<p class="sub">
|
|
You're logged in as the <strong>admin</strong>. Before you get started,
|
|
here's exactly what this account can and cannot do.
|
|
</p>
|
|
<ul class="capability-list">
|
|
<li>
|
|
<span class="cap-icon cap-can">✓</span>
|
|
<span class="cap-text">
|
|
<strong>Create user accounts</strong>
|
|
<span>You control who can log in.</span>
|
|
</span>
|
|
</li>
|
|
<li>
|
|
<span class="cap-icon cap-can">✓</span>
|
|
<span class="cap-text">
|
|
<strong>Reset user passwords</strong>
|
|
<span>If a user gets locked out, you can reset their password.</span>
|
|
</span>
|
|
</li>
|
|
<li>
|
|
<span class="cap-icon cap-cant">✗</span>
|
|
<span class="cap-text">
|
|
<strong>Cannot access any financial data</strong>
|
|
<span>Bills, payments, and tracker data are completely off-limits to this account — by design.</span>
|
|
</span>
|
|
</li>
|
|
<li>
|
|
<span class="cap-icon cap-cant">✗</span>
|
|
<span class="cap-text">
|
|
<strong>Cannot view account balances or history</strong>
|
|
<span>Your financial privacy is enforced at the API level, not just the UI.</span>
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
<button class="btn btn-primary btn-full" id="step1-next">
|
|
Got it — create my user account →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Step 2: Create first user -->
|
|
<div class="wizard-step" id="step-2">
|
|
<div class="wizard-card">
|
|
<div class="wizard-icon">👤</div>
|
|
<h2>Create Your User Account</h2>
|
|
<p class="sub">
|
|
This account will have full access to the tracker, bills, and payments.
|
|
Use this account for your day-to-day bill tracking.
|
|
</p>
|
|
<form id="onboarding-form">
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="ob-username" autocapitalize="none" minlength="3" required>
|
|
</div>
|
|
<div class="form-group" style="grid-column:1/-1"><!-- spacer --></div>
|
|
<div class="form-group">
|
|
<label>Password</label>
|
|
<input type="password" id="ob-password" minlength="8" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Confirm Password</label>
|
|
<input type="password" id="ob-confirm" minlength="8" required>
|
|
</div>
|
|
</div>
|
|
<div class="err" id="ob-error"></div>
|
|
<button type="submit" class="btn btn-primary btn-full" id="ob-submit">
|
|
Create Account & Go to Admin Panel
|
|
</button>
|
|
<button type="button" class="btn btn-ghost btn-full" id="step2-back"
|
|
style="margin-top:8px">
|
|
← Back
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- ── Normal admin panel ──────────────────────────────────────────────── -->
|
|
<div id="panel">
|
|
<main>
|
|
<div class="notice">
|
|
<strong>Admin Account Scope</strong>
|
|
<ul>
|
|
<li>You can create user accounts and reset passwords.</li>
|
|
<li>You cannot view, access, or modify any bills, payments, or financial data.</li>
|
|
<li>Users are informed that only their password can be reset by this account.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="card" id="email-notif-card">
|
|
<h2>Email Notifications</h2>
|
|
<div id="email-notif-content"><p class="muted">Loading…</p></div>
|
|
</div>
|
|
|
|
<div class="card" id="auth-mode-card">
|
|
<h2>Login Mode</h2>
|
|
<div id="auth-mode-content"><p class="muted">Loading…</p></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Add User</h2>
|
|
<form class="add-form" id="add-user-form">
|
|
<div class="form-group">
|
|
<label>Username</label>
|
|
<input type="text" id="new-username" autocapitalize="none" minlength="3" required style="min-width:160px">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Password</label>
|
|
<input type="password" id="new-password" minlength="8" required style="min-width:160px">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="align-self:flex-end">Create User</button>
|
|
</form>
|
|
<div id="add-status"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Users</h2>
|
|
<div id="users-table"><p class="muted">Loading…</p></div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
let currentUser = null;
|
|
|
|
async function init() {
|
|
const res = await fetch('/api/auth/me');
|
|
if (!res.ok) { location.href = '/login.html'; return; }
|
|
const data = await res.json();
|
|
if (data.user.role !== 'admin') { location.href = '/'; return; }
|
|
currentUser = data.user;
|
|
document.getElementById('admin-name').textContent = data.user.username;
|
|
|
|
const hasRes = await fetch('/api/admin/has-users');
|
|
const hasData = await hasRes.json();
|
|
|
|
if (!hasData.has_users) {
|
|
showOnboarding();
|
|
} else {
|
|
showPanel();
|
|
}
|
|
}
|
|
|
|
// ── Onboarding ────────────────────────────────────────────────────────────
|
|
|
|
function showOnboarding() {
|
|
document.getElementById('onboarding').style.display = 'block';
|
|
}
|
|
|
|
function showPanel() {
|
|
document.getElementById('onboarding').style.display = 'none';
|
|
document.getElementById('panel').style.display = 'block';
|
|
loadEmailNotif();
|
|
loadAuthMode();
|
|
loadUsers();
|
|
}
|
|
|
|
function setStep(n) {
|
|
document.querySelectorAll('.wizard-step').forEach((el, i) => {
|
|
el.classList.toggle('active', i + 1 === n);
|
|
});
|
|
document.querySelectorAll('.dot').forEach((el, i) => {
|
|
el.classList.toggle('active', i + 1 === n);
|
|
});
|
|
}
|
|
|
|
document.getElementById('step1-next').onclick = () => setStep(2);
|
|
document.getElementById('step2-back').onclick = () => setStep(1);
|
|
|
|
document.getElementById('onboarding-form').onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('ob-username').value.trim();
|
|
const password = document.getElementById('ob-password').value;
|
|
const confirm = document.getElementById('ob-confirm').value;
|
|
const errEl = document.getElementById('ob-error');
|
|
const btn = document.getElementById('ob-submit');
|
|
|
|
errEl.textContent = '';
|
|
|
|
if (password !== confirm) { errEl.textContent = 'Passwords do not match.'; return; }
|
|
if (password.length < 8) { errEl.textContent = 'Password must be at least 8 characters.'; return; }
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = 'Creating…';
|
|
try {
|
|
const res = await fetch('/api/admin/users', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
showPanel();
|
|
} catch (err) {
|
|
errEl.textContent = err.message;
|
|
btn.disabled = false;
|
|
btn.textContent = 'Create Account & Go to Admin Panel';
|
|
}
|
|
};
|
|
|
|
// ── Admin panel ───────────────────────────────────────────────────────────
|
|
|
|
// ── Email notifications (admin SMTP) ──────────────────────────────────────
|
|
|
|
let smtpSettings = {};
|
|
|
|
async function loadEmailNotif() {
|
|
const res = await fetch('/api/notifications/admin');
|
|
if (!res.ok) return;
|
|
smtpSettings = await res.json();
|
|
renderEmailNotif();
|
|
}
|
|
|
|
function renderEmailNotif() {
|
|
const s = smtpSettings;
|
|
const enabled = s.notify_smtp_enabled === 'true';
|
|
|
|
document.getElementById('email-notif-content').innerHTML = `
|
|
<div style="margin-bottom:16px;display:flex;align-items:center;gap:10px;">
|
|
<span class="enabled-badge ${enabled ? 'badge-on' : 'badge-off'}">
|
|
${enabled ? '● Enabled' : '○ Disabled'}
|
|
</span>
|
|
<span class="muted">${enabled ? 'Sending email notifications' : 'Email notifications are off'}</span>
|
|
</div>
|
|
|
|
<form id="smtp-form" class="smtp-form" autocomplete="off">
|
|
|
|
<label class="row-label">Enable Agent</label>
|
|
<div class="row-ctrl">
|
|
<input type="checkbox" id="s-enabled" ${enabled ? 'checked' : ''}>
|
|
</div>
|
|
|
|
<hr class="smtp-divider">
|
|
|
|
<label class="row-label">Sender Name</label>
|
|
<div class="row-ctrl">
|
|
<input type="text" id="s-sender-name" value="${esc(s.notify_sender_name)}" style="max-width:300px">
|
|
</div>
|
|
|
|
<label class="row-label">
|
|
Sender Address <span style="color:var(--danger)">*</span>
|
|
</label>
|
|
<div class="row-ctrl">
|
|
<input type="email" id="s-sender-addr" value="${esc(s.notify_sender_address)}" style="max-width:300px" placeholder="from@example.com">
|
|
</div>
|
|
|
|
<hr class="smtp-divider">
|
|
|
|
<label class="row-label">
|
|
SMTP Host <span style="color:var(--danger)">*</span>
|
|
</label>
|
|
<div class="row-ctrl">
|
|
<input type="text" id="s-host" value="${esc(s.notify_smtp_host)}" style="max-width:300px" placeholder="smtp.example.com">
|
|
</div>
|
|
|
|
<label class="row-label">
|
|
SMTP Port <span style="color:var(--danger)">*</span>
|
|
</label>
|
|
<div class="row-ctrl">
|
|
<input type="number" id="s-port" value="${esc(s.notify_smtp_port || '587')}" style="max-width:90px">
|
|
</div>
|
|
|
|
<label class="row-label">
|
|
Encryption Method <span style="color:var(--danger)">*</span>
|
|
<div class="row-sub">STARTTLS → port 587 · SSL/TLS → port 465</div>
|
|
</label>
|
|
<div class="row-ctrl">
|
|
<select id="s-encryption" style="max-width:240px">
|
|
<option value="starttls" ${s.notify_smtp_encryption==='starttls'?'selected':''}>STARTTLS</option>
|
|
<option value="ssl" ${s.notify_smtp_encryption==='ssl'?'selected':''}>SSL / TLS</option>
|
|
<option value="none" ${s.notify_smtp_encryption==='none'?'selected':''}>None</option>
|
|
</select>
|
|
</div>
|
|
|
|
<label class="row-label">Allow Self-Signed Certificates</label>
|
|
<div class="row-ctrl">
|
|
<input type="checkbox" id="s-self-signed" ${s.notify_smtp_self_signed==='true'?'checked':''}>
|
|
</div>
|
|
|
|
<hr class="smtp-divider">
|
|
|
|
<label class="row-label">SMTP Username</label>
|
|
<div class="row-ctrl">
|
|
<input type="text" id="s-user" value="${esc(s.notify_smtp_username)}" autocomplete="new-password" style="max-width:300px">
|
|
</div>
|
|
|
|
<label class="row-label">SMTP Password</label>
|
|
<div class="row-ctrl">
|
|
<div class="pw-wrap" style="max-width:300px">
|
|
<input type="password" id="s-pass" value="${esc(s.notify_smtp_password)}" autocomplete="new-password">
|
|
<button type="button" class="pw-toggle" onclick="togglePw('s-pass',this)">👁</button>
|
|
</div>
|
|
</div>
|
|
|
|
<hr class="smtp-divider">
|
|
|
|
<label class="row-label">
|
|
Allow users to configure<br>their own notification email
|
|
</label>
|
|
<div class="row-ctrl">
|
|
<input type="checkbox" id="s-allow-user" ${s.notify_allow_user_config==='true'?'checked':''}>
|
|
</div>
|
|
|
|
<label class="row-label">
|
|
Global recipient email
|
|
<div class="row-sub">Used when user config is disabled</div>
|
|
</label>
|
|
<div class="row-ctrl">
|
|
<input type="email" id="s-global-recipient" value="${esc(s.notify_global_recipient)}" style="max-width:300px" placeholder="you@example.com">
|
|
</div>
|
|
|
|
<div class="test-row">
|
|
<input type="email" id="s-test-to" placeholder="Send test to…" style="max-width:240px">
|
|
<button type="button" class="btn btn-ghost btn-sm" id="s-test-btn">Send Test Email</button>
|
|
<span id="s-test-status" class="muted"></span>
|
|
</div>
|
|
|
|
<div class="notif-actions">
|
|
<button type="submit" class="btn btn-primary">Save</button>
|
|
</div>
|
|
|
|
</form>
|
|
<div id="smtp-save-status" style="text-align:right;margin-top:6px;font-size:13px;min-height:18px;"></div>
|
|
`;
|
|
|
|
document.getElementById('smtp-form').onsubmit = saveEmailNotif;
|
|
document.getElementById('s-test-btn').onclick = sendTestEmail;
|
|
|
|
// Auto-fill port on encryption change
|
|
document.getElementById('s-encryption').onchange = function() {
|
|
const portEl = document.getElementById('s-port');
|
|
if (this.value === 'ssl') portEl.value = '465';
|
|
else if (portEl.value === '465') portEl.value = '587';
|
|
};
|
|
}
|
|
|
|
async function saveEmailNotif(e) {
|
|
e.preventDefault();
|
|
const btn = e.target.querySelector('[type="submit"]');
|
|
btn.disabled = true;
|
|
const payload = {
|
|
notify_smtp_enabled: document.getElementById('s-enabled').checked ? 'true' : 'false',
|
|
notify_sender_name: document.getElementById('s-sender-name').value,
|
|
notify_sender_address: document.getElementById('s-sender-addr').value,
|
|
notify_smtp_host: document.getElementById('s-host').value,
|
|
notify_smtp_port: document.getElementById('s-port').value,
|
|
notify_smtp_encryption: document.getElementById('s-encryption').value,
|
|
notify_smtp_self_signed: document.getElementById('s-self-signed').checked ? 'true' : 'false',
|
|
notify_smtp_username: document.getElementById('s-user').value,
|
|
notify_smtp_password: document.getElementById('s-pass').value,
|
|
notify_allow_user_config:document.getElementById('s-allow-user').checked ? 'true' : 'false',
|
|
notify_global_recipient: document.getElementById('s-global-recipient').value,
|
|
};
|
|
try {
|
|
const res = await fetch('/api/notifications/admin', {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) throw new Error((await res.json()).error);
|
|
smtpSettings = { ...smtpSettings, ...payload };
|
|
document.getElementById('smtp-save-status').innerHTML = '<span class="ok">Saved.</span>';
|
|
renderEmailNotif(); // re-render badge
|
|
} catch (err) {
|
|
document.getElementById('smtp-save-status').innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function sendTestEmail() {
|
|
const to = document.getElementById('s-test-to').value.trim();
|
|
const el = document.getElementById('s-test-status');
|
|
const btn = document.getElementById('s-test-btn');
|
|
if (!to) { el.textContent = 'Enter a recipient.'; return; }
|
|
btn.disabled = true;
|
|
el.textContent = 'Sending…';
|
|
try {
|
|
const res = await fetch('/api/notifications/test', {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ to }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
el.innerHTML = '<span class="ok">Sent!</span>';
|
|
} catch (err) {
|
|
el.innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function togglePw(id, btn) {
|
|
const inp = document.getElementById(id);
|
|
inp.type = inp.type === 'password' ? 'text' : 'password';
|
|
btn.textContent = inp.type === 'password' ? '👁' : '🙈';
|
|
}
|
|
|
|
// ── Auth mode ─────────────────────────────────────────────────────────────
|
|
|
|
async function loadAuthMode() {
|
|
const [modeRes, usersRes] = await Promise.all([
|
|
fetch('/api/admin/auth-mode'),
|
|
fetch('/api/admin/users'),
|
|
]);
|
|
const { auth_mode, default_user_id } = await modeRes.json();
|
|
const users = await usersRes.json();
|
|
const regularUsers = users.filter(u => u.role === 'user');
|
|
|
|
const el = document.getElementById('auth-mode-content');
|
|
|
|
if (auth_mode === 'single') {
|
|
const defaultUser = regularUsers.find(u => u.id == default_user_id);
|
|
el.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
<span style="background:var(--warning-light);color:var(--warning);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700">
|
|
Single-User Mode Active
|
|
</span>
|
|
<span class="muted">Auto-logged in as: <strong>${esc(defaultUser?.username || '(unknown)')}</strong></span>
|
|
<button class="btn btn-ghost btn-sm" id="revert-multi-btn">Restore Login Requirement</button>
|
|
</div>
|
|
<p class="muted" style="margin-top:10px;font-size:12px;line-height:1.5">
|
|
Anyone who opens the app is automatically signed in as
|
|
<strong>${esc(defaultUser?.username || '(unknown)')}</strong> — no password required.
|
|
Only the admin login page still requires authentication.
|
|
</p>
|
|
`;
|
|
document.getElementById('revert-multi-btn').onclick = async () => {
|
|
await setAuthMode('multi', null);
|
|
loadAuthMode();
|
|
};
|
|
} else {
|
|
const userOptions = regularUsers.map(u =>
|
|
`<option value="${u.id}">${esc(u.username)}</option>`
|
|
).join('');
|
|
|
|
el.innerHTML = `
|
|
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px">
|
|
<span style="background:var(--success-light);color:var(--success);padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700">
|
|
Normal Login Active
|
|
</span>
|
|
<span class="muted">All users must sign in with their password.</span>
|
|
</div>
|
|
${regularUsers.length === 0
|
|
? `<p class="muted" style="font-size:12px">Create at least one user to enable single-user mode.</p>`
|
|
: `<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap">
|
|
<div class="form-group">
|
|
<label>Auto-login as</label>
|
|
<select id="single-user-select" style="min-width:150px">
|
|
${userOptions}
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-ghost btn-sm" id="enable-single-btn">Enable Single-User Mode</button>
|
|
</div>
|
|
<p class="muted" style="margin-top:8px;font-size:12px;line-height:1.5">
|
|
Single-user mode removes the login screen. Anyone who opens the app gets
|
|
access as the selected user. The admin login at <code>/admin.html</code>
|
|
still requires a password.
|
|
</p>`
|
|
}
|
|
`;
|
|
|
|
if (regularUsers.length > 0) {
|
|
document.getElementById('enable-single-btn').onclick = async () => {
|
|
const userId = document.getElementById('single-user-select').value;
|
|
if (!confirm('Enable single-user mode? Anyone who opens the app will be signed in automatically.')) return;
|
|
await setAuthMode('single', userId);
|
|
loadAuthMode();
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
async function setAuthMode(mode, userId) {
|
|
const res = await fetch('/api/admin/auth-mode', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ auth_mode: mode, default_user_id: userId }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) { panelStatus('Error: ' + data.error, 'err'); return; }
|
|
panelStatus(mode === 'single' ? 'Single-user mode enabled.' : 'Login restored.', 'ok');
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const res = await fetch('/api/admin/users');
|
|
if (!res.ok) return;
|
|
const users = await res.json();
|
|
|
|
const tbody = users.map(u => `
|
|
<tr>
|
|
<td>${esc(u.username)}</td>
|
|
<td><span class="role-badge role-${u.role}">${u.role}</span></td>
|
|
<td>${u.must_change_password
|
|
? '<span style="color:var(--warning)">Must change</span>'
|
|
: '<span class="muted">OK</span>'}</td>
|
|
<td>
|
|
${u.role !== 'admin' ? `
|
|
<form class="reset-form" onsubmit="resetPassword(event,${u.id})">
|
|
<input type="password" placeholder="New password" minlength="8" required>
|
|
<button type="submit" class="btn btn-ghost btn-sm">Reset</button>
|
|
<button type="button" class="btn btn-danger btn-sm"
|
|
onclick="deleteUser(${u.id},'${esc(u.username)}')">Delete</button>
|
|
</form>
|
|
` : '<span class="muted">—</span>'}
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
|
|
document.getElementById('users-table').innerHTML = `
|
|
<table>
|
|
<thead><tr><th>Username</th><th>Role</th><th>Password</th><th>Actions</th></tr></thead>
|
|
<tbody>${tbody || '<tr><td colspan="4" class="muted">No users yet.</td></tr>'}</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
async function resetPassword(e, userId) {
|
|
e.preventDefault();
|
|
const input = e.target.querySelector('input[type="password"]');
|
|
const btn = e.target.querySelector('[type="submit"]');
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch(`/api/admin/users/${userId}/password`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: input.value }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
input.value = '';
|
|
panelStatus('Password reset. User will be prompted to change it on next login.', 'ok');
|
|
loadUsers();
|
|
} catch (err) {
|
|
panelStatus('Error: ' + err.message, 'err');
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function deleteUser(userId, username) {
|
|
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
|
try {
|
|
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
panelStatus(`User "${username}" deleted.`, 'ok');
|
|
loadAuthMode();
|
|
loadUsers();
|
|
} catch (err) {
|
|
panelStatus('Error: ' + err.message, 'err');
|
|
}
|
|
}
|
|
|
|
document.getElementById('add-user-form').onsubmit = async (e) => {
|
|
e.preventDefault();
|
|
const btn = e.target.querySelector('[type="submit"]');
|
|
btn.disabled = true;
|
|
document.getElementById('add-status').textContent = '';
|
|
try {
|
|
const res = await fetch('/api/admin/users', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
username: document.getElementById('new-username').value,
|
|
password: document.getElementById('new-password').value,
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error);
|
|
document.getElementById('new-username').value = '';
|
|
document.getElementById('new-password').value = '';
|
|
document.getElementById('add-status').innerHTML = `<span class="ok">User "${esc(data.username)}" created.</span>`;
|
|
loadAuthMode();
|
|
loadUsers();
|
|
} catch (err) {
|
|
document.getElementById('add-status').innerHTML = `<span class="err">${esc(err.message)}</span>`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
};
|
|
|
|
let panelTimer;
|
|
function panelStatus(msg, cls) {
|
|
const el = document.getElementById('panel-status') || (() => {
|
|
const d = document.createElement('div');
|
|
d.id = 'panel-status';
|
|
document.getElementById('users-table').before(d);
|
|
return d;
|
|
})();
|
|
el.innerHTML = `<span class="${cls}">${esc(msg)}</span>`;
|
|
clearTimeout(panelTimer);
|
|
panelTimer = setTimeout(() => el.textContent = '', 4000);
|
|
}
|
|
|
|
document.getElementById('logout-btn').onclick = async () => {
|
|
await fetch('/api/auth/logout', { method: 'POST' });
|
|
location.href = '/login.html';
|
|
};
|
|
|
|
function esc(s) {
|
|
return String(s||'').replace(/&/g,'&').replace(/</g,'<')
|
|
.replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|