BillTracker/public/admin.html

767 lines
34 KiB
HTML
Raw Normal View History

2026-05-03 19:51:57 -05:00
<!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">&#128274;</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">&#10003;</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">&#10003;</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">&#10007;</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">&#10007;</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 &rarr;
</button>
</div>
</div>
<!-- Step 2: Create first user -->
<div class="wizard-step" id="step-2">
<div class="wizard-card">
<div class="wizard-icon">&#128100;</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 &amp; Go to Admin Panel
</button>
<button type="button" class="btn btn-ghost btn-full" id="step2-back"
style="margin-top:8px">
&larr; 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 &nbsp;·&nbsp; 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)">&#128065;</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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
init();
</script>
</body>
</html>