chore(cleanup): remove legacy/public HTML files, retire /legacy route, update docs and About page

This commit is contained in:
null 2026-06-11 23:50:27 -05:00
parent c6708982a9
commit d0835b86ab
29 changed files with 11 additions and 6457 deletions

View File

@ -5,6 +5,10 @@
- **Cents Migration Stage 2 — schema flip to integer cents** — The 12 dollar (REAL) columns across 8 tables defined in the cents-migration plan are now integer cents at rest. Migration v1.03 converts and back-fills existing rows; the schema, ~288 query sites in routes and services, CSV/spreadsheet import inserts, `userDbImportService` unit detection, and test fixtures are all cents-aware. CSV export divides by 100 for display. Float dollars are eliminated before the data grows further, and the units now match SimpleFIN transactions/accounts (already cents). Stage 1's `utils/money.js` remains the single source of truth for arithmetic. The full plan, migration SQL, and verification checklist live in `docs/cents-migration-plan.md`. - **Cents Migration Stage 2 — schema flip to integer cents** — The 12 dollar (REAL) columns across 8 tables defined in the cents-migration plan are now integer cents at rest. Migration v1.03 converts and back-fills existing rows; the schema, ~288 query sites in routes and services, CSV/spreadsheet import inserts, `userDbImportService` unit detection, and test fixtures are all cents-aware. CSV export divides by 100 for display. Float dollars are eliminated before the data grows further, and the units now match SimpleFIN transactions/accounts (already cents). Stage 1's `utils/money.js` remains the single source of truth for arithmetic. The full plan, migration SQL, and verification checklist live in `docs/cents-migration-plan.md`.
### 🧹 Cleanup
- **Retired static UI removed** — Deleted the old `legacy/` and root `public/` static UI copies, including the broken duplicate `public/js/api.js` client. The server now returns `410 Gone` for `/legacy` instead of serving stale assets, the hidden About-page legacy link is gone, and current docs no longer list the retired static UI as part of the runtime structure.
## v0.37.0 ## v0.37.0
### ✨ Added ### ✨ Added

View File

@ -448,7 +448,6 @@ bill-tracker/
| `-- pages/ # Route pages | `-- pages/ # Route pages
|-- db/ # SQLite schema, migrations, and database helpers |-- db/ # SQLite schema, migrations, and database helpers
|-- docs/ # Technical references and README screenshots |-- docs/ # Technical references and README screenshots
|-- legacy/ # Legacy static UI retained for reference
|-- middleware/ # Auth, CSRF, rate limit, security, and error middleware |-- middleware/ # Auth, CSRF, rate limit, security, and error middleware
|-- routes/ # Express API route handlers |-- routes/ # Express API route handlers
|-- scripts/ # Utility, migration, deployment, and smoke-test scripts |-- scripts/ # Utility, migration, deployment, and smoke-test scripts

View File

@ -10,12 +10,11 @@
#### CSRF Token Handling Fixes #### CSRF Token Handling Fixes
**Issue:** Create user and other state-changing requests failing with CSRF errors. **Issue:** Create user and other state-changing requests failing with CSRF errors.
**Root Cause:** Legacy and public API clients not sending CSRF tokens. **Root Cause:** Retired static API clients were not sending CSRF tokens.
**Fixes Applied:** **Fixes Applied:**
1. `client/api.js` - ✅ Already correct 1. `client/api.js` - ✅ Already correct
2. `legacy/js/api.js` - ✅ Fixed - added `credentials: 'include'` and CSRF token extraction 2. Retired `legacy/js/api.js` and `public/js/api.js` - ✅ Removed with the static legacy UI cleanup
3. `public/js/api.js` - ✅ Fixed - added `credentials: 'include'` and CSRF token extraction
#### CSRF Cookie httpOnly Configuration (Neo) - 2026-05-08 #### CSRF Cookie httpOnly Configuration (Neo) - 2026-05-08
**Issue:** Need configurable CSRF cookie httpOnly setting for SPA vs secure mode. **Issue:** Need configurable CSRF cookie httpOnly setting for SPA vs secure mode.
@ -922,4 +921,3 @@ Command failed: cd /home/kaspa/.openclaw/Projects/bill-tracker && npx playwright
The notes feature is implemented as **per-bill AND per-month**. Each bill has its own notes field, and each month has its own separate notes. The notes feature is implemented as **per-bill AND per-month**. Each bill has its own notes field, and each month has its own separate notes.
--- ---

View File

@ -156,20 +156,6 @@ export default function AboutPage() {
</CardContent> </CardContent>
</Card> </Card>
</main> </main>
{/* Easter egg — barely visible, reveals on hover for curious explorers */}
<div className="flex justify-center pt-10 pb-4">
<a
href="/legacy"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] text-muted-foreground/10 hover:text-muted-foreground/50 transition-colors duration-1000 select-none tracking-widest uppercase"
tabIndex={-1}
aria-hidden="true"
>
remember when
</a>
</div>
</div> </div>
); );
} }

View File

@ -83,7 +83,7 @@ Global middleware order:
4. `cookieParser()` 4. `cookieParser()`
5. `csrfTokenProvider` 5. `csrfTokenProvider`
6. mounted API routers with route-level rate-limit/auth/CSRF middleware 6. mounted API routers with route-level rate-limit/auth/CSRF middleware
7. static `legacy/`, redirect `/login.html` to `/login`, static `dist/` 7. retired `/legacy` route returns 410, redirect `/login.html` to `/login`, static `dist/`
8. SPA fallback `GET *` serving `dist/index.html` after ensuring a CSRF token cookie 8. SPA fallback `GET *` serving `dist/index.html` after ensuring a CSRF token cookie
9. `errorFormatter` 9. `errorFormatter`
10. final JSON error handler for malformed JSON/body size/runtime errors 10. final JSON error handler for malformed JSON/body size/runtime errors

View File

@ -1,766 +0,0 @@
<!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>

View File

@ -1,888 +0,0 @@
/* ── Reset & base ── */
*, *::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);
--success: #22d3a5;
--success-light: rgba(34,211,165,0.15);
--danger: #f43f5e;
--danger-light: rgba(244,63,94,0.15);
--warning: #fb923c;
--warning-light: rgba(251,146,60,0.15);
--info: #38bdf8;
--info-light: rgba(56,189,248,0.15);
--sidebar-w: 200px;
--header-h: 56px;
--radius: 6px;
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
font-family: var(--font);
font-size: 14px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
/* ── Layout ── */
#app {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-w);
background: #0a1120;
color: var(--text-muted);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
border-right: 1px solid var(--border);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.logo-icon {
width: 30px; height: 30px;
background: var(--primary);
border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 15px; color: white;
box-shadow: 0 0 12px rgba(99,102,241,0.35);
}
.logo-text {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
.nav-links {
list-style: none;
padding: 0 8px;
}
.nav-links li { margin: 2px 0; }
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.nav-link:hover { background: var(--surface-2); color: var(--text); }
.nav-link.active { background: var(--primary-light); color: var(--primary); }
.nav-icon { font-size: 12px; opacity: .8; }
.sidebar-footer {
margin-top: auto;
padding: 8px 8px 12px;
border-top: 1px solid var(--border);
}
#sidebar-username {
display: block;
font-size: 11px;
color: var(--text-faint);
padding: 4px 10px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-logout {
font-size: 12px !important;
opacity: .7;
}
.sidebar-logout:hover { opacity: 1; }
.main-content {
margin-left: var(--sidebar-w);
flex: 1;
min-height: 100vh;
padding: 24px;
}
.page { display: none; }
.page.active { display: block; }
/* ── Page header ── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
/* ── Summary cards ── */
.summary-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.summary-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.summary-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--border-strong);
}
.summary-card.danger::before { background: var(--danger); }
.summary-card.success::before { background: var(--success); }
.summary-card.warning::before { background: var(--warning); }
.summary-card .label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-muted);
margin-bottom: 6px;
}
.summary-card .value {
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.summary-card.danger .value { color: var(--danger); }
.summary-card.success .value { color: var(--success); }
.summary-card.warning .value { color: var(--warning); }
/* ── Month nav ── */
.month-nav {
display: flex;
align-items: center;
gap: 12px;
}
.month-nav .month-label {
font-size: 16px;
font-weight: 600;
min-width: 130px;
text-align: center;
color: var(--text);
}
/* ── Tracker table ── */
.bucket-section {
margin-bottom: 24px;
}
.bucket-header {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
margin-bottom: 8px;
border-bottom: 2px solid var(--border);
}
.bucket-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-muted);
}
.bucket-totals {
font-size: 12px;
color: var(--text-faint);
margin-left: auto;
}
.tracker-table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.tracker-table th {
background: var(--surface-2);
padding: 9px 12px;
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
white-space: nowrap;
}
.tracker-table td {
padding: 0;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.tracker-table tr:last-child td { border-bottom: none; }
.tracker-table tr:hover td { background: var(--surface-2); }
.tracker-table tr.row-paid:hover td { background: rgba(34,211,165,0.07); }
.tracker-table tr.row-late:hover td { background: rgba(251,146,60,0.07); }
.tracker-table tr.row-missed:hover td { background: rgba(244,63,94,0.07); }
.tracker-table tr.row-paid td { background: rgba(34,211,165,0.04); }
.tracker-table tr.row-late td { background: rgba(251,146,60,0.04); }
.tracker-table tr.row-missed td { background: rgba(244,63,94,0.04); }
.tracker-table tr.row-autodraft td { background: rgba(251,146,60,0.04); }
.td-inner {
padding: 10px 12px;
display: flex;
align-items: center;
gap: 6px;
min-height: 44px;
}
/* ── Status badge ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
letter-spacing: .02em;
}
.badge-paid { background: var(--success-light); color: var(--success); }
.badge-upcoming { background: var(--surface-3); color: var(--text-muted); }
.badge-due-soon { background: var(--warning-light); color: var(--warning); }
.badge-late { background: var(--warning-light); color: var(--warning); }
.badge-missed { background: var(--danger-light); color: var(--danger); }
.badge-autodraft { background: var(--warning-light); color: var(--warning); }
/* ── Inline editable cells ── */
.editable-cell {
cursor: pointer;
border-radius: 4px;
padding: 4px 6px;
min-width: 80px;
transition: background .1s;
}
.editable-cell:hover { background: var(--primary-light); }
.editable-cell.empty { color: var(--text-faint); }
.editable-cell input {
border: none;
outline: none;
background: transparent;
font-size: inherit;
font-family: inherit;
color: var(--text);
width: 100%;
min-width: 80px;
}
/* ── Bill name cell ── */
.bill-name-cell {
font-weight: 500;
color: var(--text);
}
.bill-category {
font-size: 11px;
color: var(--text-faint);
}
/* ── Quick pay group ── */
.quick-pay-group {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.quick-pay-group input[type="number"] {
width: 80px;
padding: 4px 8px;
font-size: 12px;
}
/* ── Action buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: none;
border-radius: var(--radius);
font-size: 13px;
font-family: var(--font);
font-weight: 500;
cursor: pointer;
transition: background .15s, color .15s, opacity .15s, box-shadow .15s;
white-space: nowrap;
}
.btn:disabled { opacity: .4; cursor: not-allowed; }
.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-success { background: var(--success); color: #0d1526; }
.btn-success:hover:not(:disabled) { background: #1ab890; }
.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); border-color: var(--border-strong); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover:not(:disabled) { background: #e11d48; }
.btn-sm { padding: 4px 8px; font-size: 12px; }
.btn-pay {
background: var(--primary-light);
color: var(--primary);
border: 1px solid rgba(99,102,241,0.3);
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius);
}
.btn-pay:hover { background: var(--primary); color: white; border-color: var(--primary); }
.btn-icon {
background: transparent;
color: var(--text-faint);
border: none;
padding: 4px 6px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
transition: background .15s, color .15s;
}
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
/* ── Action cell ── */
.action-cell {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 10px;
}
/* ── Amount display ── */
.amount-expected { color: var(--text-muted); font-size: 13px; }
.amount-actual { font-weight: 600; color: var(--text); }
.amount-mismatch { color: var(--warning); }
/* ── Bills management page ── */
.bills-grid {
display: grid;
gap: 10px;
}
.bill-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: var(--shadow);
transition: border-color .15s, background .15s;
}
.bill-card:hover { border-color: var(--primary); background: var(--surface-2); }
.bill-card.inactive { opacity: .45; }
.bill-card-info { flex: 1; min-width: 0; }
.bill-card-name { font-weight: 600; font-size: 14px; color: var(--text); }
.bill-card-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.bill-card-amount {
font-size: 16px;
font-weight: 700;
color: var(--text);
min-width: 80px;
text-align: right;
}
.bill-card-actions { display: flex; gap: 6px; }
/* ── Categories page ── */
.cat-list { display: flex; flex-direction: column; gap: 8px; }
.cat-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: var(--shadow);
transition: border-color .15s;
}
.cat-item:hover { border-color: var(--border-strong); }
.cat-name { flex: 1; font-weight: 500; color: var(--text); }
.cat-add-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
/* ── Settings page ── */
.settings-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.settings-section h3 {
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.settings-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.settings-row:last-child { margin-bottom: 0; }
.settings-row label {
min-width: 180px;
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
/* ── Forms ── */
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.form-group.full-width { grid-column: 1 / -1; }
.form-group.checkbox-group { flex-direction: row; flex-wrap: wrap; gap: 16px; }
.form-group.checkbox-group label {
display: flex; align-items: center; gap: 6px;
text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 500; color: var(--text);
cursor: pointer;
}
input[type="text"],
input[type="number"],
input[type="date"],
input[type="email"],
input[type="password"],
select,
textarea {
padding: 7px 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);
transition: border-color .15s, box-shadow .15s;
width: 100%;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
}
select option {
background: var(--surface-2);
color: var(--text);
}
input::placeholder { color: var(--text-faint); }
textarea::placeholder { color: var(--text-faint); }
textarea { resize: vertical; }
input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--primary);
cursor: pointer;
}
/* ── Modal ── */
.modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden { display: none; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.65);
backdrop-filter: blur(2px);
}
.modal-box {
position: relative;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 10px;
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border);
padding: 24px;
}
.modal-box-sm { max-width: 380px; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-header h2 {
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.modal-close {
background: none;
border: none;
font-size: 22px;
color: var(--text-faint);
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color .15s;
}
.modal-close:hover { color: var(--text); }
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
/* ── Toast notifications ── */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999;
display: flex;
flex-direction: column-reverse;
gap: 8px;
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 280px;
max-width: 380px;
padding: 12px 14px 12px 16px;
border-radius: var(--radius);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-left-width: 3px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04);
font-size: 13px;
font-weight: 500;
color: var(--text);
opacity: 0;
transform: translateX(20px) translateY(4px);
transition: opacity .25s ease, transform .25s ease;
pointer-events: auto;
}
.toast.show {
opacity: 1;
transform: translateX(0) translateY(0);
}
.toast.hide {
opacity: 0;
transform: translateX(20px) translateY(4px);
transition: opacity .2s ease, transform .2s ease;
}
.toast-icon {
font-size: 15px;
flex-shrink: 0;
margin-top: 1px;
}
.toast-body { flex: 1; line-height: 1.4; }
.toast.success { border-left-color: var(--success); }
.toast.success .toast-icon { color: var(--success); }
.toast.error { border-left-color: var(--danger); }
.toast.error .toast-icon { color: var(--danger); }
.toast.warning { border-left-color: var(--warning); }
.toast.warning .toast-icon { color: var(--warning); }
.toast.info { border-left-color: var(--info); }
.toast.info .toast-icon { color: var(--info); }
/* Legacy single #toast element (backwards compatibility) */
#toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--surface-2);
color: var(--text);
padding: 11px 16px 11px 18px;
border-radius: var(--radius);
border: 1px solid var(--border-strong);
border-left: 3px solid var(--primary);
font-size: 13px;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
z-index: 999;
opacity: 0;
transform: translateX(20px);
transition: opacity .25s, transform .25s;
pointer-events: none;
max-width: 360px;
}
#toast.show { opacity: 1; transform: translateX(0); }
#toast.success { border-left-color: var(--success); }
#toast.error { border-left-color: var(--danger); }
#toast.warning { border-left-color: var(--warning); }
/* ── Status page ── */
.status-page {
max-width: 700px;
margin: 0 auto;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.status-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 14px;
}
.status-dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex-shrink: 0;
position: relative;
}
.status-dot.green {
background: var(--success);
box-shadow: 0 0 0 0 rgba(34,211,165,0.4);
animation: pulse-green 2s infinite;
}
.status-dot.red {
background: var(--danger);
box-shadow: 0 0 0 0 rgba(244,63,94,0.4);
animation: pulse-red 2s infinite;
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(34,211,165,0.5); }
70% { box-shadow: 0 0 0 7px rgba(34,211,165,0); }
100% { box-shadow: 0 0 0 0 rgba(34,211,165,0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(244,63,94,0.5); }
70% { box-shadow: 0 0 0 7px rgba(244,63,94,0); }
100% { box-shadow: 0 0 0 0 rgba(244,63,94,0); }
}
.stat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.stat-row:last-child { border-bottom: none; }
.stat-row .stat-label { color: var(--text-muted); }
.stat-row .stat-value { font-weight: 600; color: var(--text); }
/* ── Misc ── */
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--text-faint);
}
.empty-state p { font-size: 14px; margin-top: 8px; }
.loading {
text-align: center;
padding: 32px;
color: var(--text-faint);
font-size: 13px;
}
.autopay-dot {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--warning);
margin-right: 2px;
vertical-align: middle;
}
.text-muted { color: var(--text-muted); }
.text-faint { color: var(--text-faint); }
.text-sm { font-size: 12px; }
.mt-1 { margin-top: 4px; }
.gap-8 { gap: 8px; }
/* ── Divider ── */
hr {
border: none;
border-top: 1px solid var(--border);
margin: 12px 0;
}
/* ── Code / monospace ── */
code {
background: var(--surface-3);
color: var(--info);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.sidebar { width: 60px; }
.logo-text, .nav-link span:not(.nav-icon) { display: none; }
.main-content { margin-left: 60px; padding: 16px; }
.summary-bar { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
}

View File

@ -1,234 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bill Tracker</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app">
<nav class="sidebar">
<div class="logo">
<span class="logo-icon">$</span>
<span class="logo-text">BillTracker</span>
</div>
<ul class="nav-links">
<li><a href="#tracker" class="nav-link active" data-page="tracker">
<span class="nav-icon">&#9776;</span> Tracker
</a></li>
<li><a href="#bills" class="nav-link" data-page="bills">
<span class="nav-icon">&#9679;</span> Bills
</a></li>
<li><a href="#categories" class="nav-link" data-page="categories">
<span class="nav-icon">&#9670;</span> Categories
</a></li>
<li><a href="#settings" class="nav-link" data-page="settings">
<span class="nav-icon">&#9881;</span> Settings
</a></li>
<li><a href="#status" class="nav-link" data-page="status">
<span class="nav-icon">&#9210;</span> Status
</a></li>
</ul>
<div class="sidebar-footer">
<span id="sidebar-username"></span>
<a href="#" id="sidebar-logout" class="nav-link sidebar-logout">
<span class="nav-icon">&#8592;</span> Sign Out
</a>
</div>
</nav>
<main class="main-content">
<div id="page-tracker" class="page active"></div>
<div id="page-bills" class="page"></div>
<div id="page-categories" class="page"></div>
<div id="page-settings" class="page"></div>
<div id="page-status" class="page"></div>
</main>
</div>
<!-- Bill Modal -->
<div id="bill-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box">
<div class="modal-header">
<h2 id="bill-modal-title">Add Bill</h2>
<button class="modal-close" id="bill-modal-close">&times;</button>
</div>
<form id="bill-form" class="modal-form">
<input type="hidden" id="bill-id">
<div class="form-grid">
<div class="form-group">
<label for="bill-name">Name *</label>
<input type="text" id="bill-name" placeholder="e.g. Electricity" required>
</div>
<div class="form-group">
<label for="bill-category">Category</label>
<select id="bill-category">
<option value="">— none —</option>
</select>
</div>
<div class="form-group">
<label for="bill-due-day">Due Day (131) *</label>
<input type="number" id="bill-due-day" min="1" max="31" required>
</div>
<div class="form-group">
<label for="bill-expected">Expected Amount ($)</label>
<input type="number" id="bill-expected" min="0" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label for="bill-interest-rate">Interest rate (APR %)</label>
<input type="number" id="bill-interest-rate" min="0" max="100" step="0.01" placeholder="Optional">
</div>
<div class="form-group">
<label for="bill-cycle">Billing Cycle</label>
<select id="bill-cycle">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="annually">Annually</option>
<option value="irregular">Irregular</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="bill-autopay"> Autopay / Autodraft
</label>
<label>
<input type="checkbox" id="bill-2fa"> Has 2FA
</label>
</div>
<div class="form-group">
<label for="bill-website">Website</label>
<input type="text" id="bill-website" placeholder="https://...">
</div>
<div class="form-group">
<label for="bill-username">Username / Email</label>
<input type="text" id="bill-username">
</div>
<div class="form-group">
<label for="bill-account-info">Account Info</label>
<input type="text" id="bill-account-info" placeholder="Last 4 digits, account #...">
</div>
<div class="form-group full-width">
<label for="bill-notes">Notes</label>
<textarea id="bill-notes" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" id="bill-modal-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save Bill</button>
</div>
</form>
</div>
</div>
<!-- Payment Modal -->
<div id="payment-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box modal-box-sm">
<div class="modal-header">
<h2 id="payment-modal-title">Edit Payment</h2>
<button class="modal-close" id="payment-modal-close">&times;</button>
</div>
<form id="payment-form" class="modal-form">
<input type="hidden" id="payment-bill-id">
<input type="hidden" id="payment-id">
<div class="form-group">
<label for="payment-amount">Amount ($) *</label>
<input type="number" id="payment-amount" min="0" step="0.01" required>
</div>
<div class="form-group">
<label for="payment-date">Paid Date *</label>
<input type="date" id="payment-date" required>
</div>
<div class="form-group">
<label for="payment-method">Method</label>
<select id="payment-method">
<option value=""></option>
<option value="bank">Bank Transfer</option>
<option value="card">Card</option>
<option value="autopay">Autopay</option>
<option value="check">Check</option>
<option value="cash">Cash</option>
</select>
</div>
<div class="form-group">
<label for="payment-notes">Notes</label>
<input type="text" id="payment-notes">
</div>
<!-- UPDATED FOOTER -->
<div class="modal-footer">
<button
type="button"
id="payment-delete"
class="btn btn-danger"
style="margin-right:auto"
title="Removes this payment record and marks the bill as unpaid. The bill itself is NOT deleted.">
Remove Payment
</button>
<button type="button" id="payment-modal-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
<!-- Privacy notice — shown on first login -->
<div id="privacy-overlay" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box modal-box-sm" style="text-align:left">
<div style="font-size:22px;margin-bottom:12px">&#128274;</div>
<h2 style="font-size:17px;margin-bottom:12px">Your data is private</h2>
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:14px">
The <strong>admin account</strong> on this system can only:
</p>
<ul style="margin:0 0 16px 20px;color:var(--text-muted);font-size:14px;line-height:1.9">
<li>Create new user accounts</li>
<li>Reset your password if you're locked out</li>
</ul>
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:20px">
The admin <strong>cannot</strong> view, edit, or access your bills,
payments, or any financial data — by design.
</p>
<button class="btn btn-primary" id="privacy-ack-btn" style="width:100%">Got it, take me to my tracker</button>
</div>
</div>
<!-- Password change — shown when must_change_password is set -->
<div id="change-password-overlay" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box modal-box-sm">
<h2 style="margin-bottom:6px">Change Your Password</h2>
<p style="color:var(--text-muted);font-size:13px;margin-bottom:18px">
Your password was reset by the admin. Please set a new one to continue.
</p>
<form id="change-password-form">
<div class="form-group" style="margin-bottom:12px">
<label for="cp-new">New Password</label>
<input type="password" id="cp-new" minlength="8" required placeholder="At least 8 characters">
</div>
<div class="form-group" style="margin-bottom:16px">
<label for="cp-confirm">Confirm Password</label>
<input type="password" id="cp-confirm" minlength="8" required>
</div>
<div id="cp-error" style="color:var(--danger);font-size:13px;margin-bottom:10px;min-height:18px"></div>
<button type="submit" class="btn btn-primary" style="width:100%">Set New Password</button>
</form>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/tracker.js"></script>
<script src="/js/bills.js"></script>
<script src="/js/categories.js"></script>
<script src="/js/settings.js"></script>
<script src="/js/status.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@ -1,51 +0,0 @@
/* Thin API client — all fetch calls go through here */
const API = {
async _fetch(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
// Add CSRF token header for state-changing methods
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
const name = 'bt_csrf_token';
const match = document.cookie.match(new RegExp(name + '=([^;]+)'));
if (match) opts.headers['x-csrf-token'] = match[1];
}
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
},
get: (path) => API._fetch('GET', path),
post: (path, body) => API._fetch('POST', path, body),
put: (path, body) => API._fetch('PUT', path, body),
delete: (path) => API._fetch('DELETE', path),
// Tracker
tracker: (year, month) => API.get(`/tracker?year=${year}&month=${month}`),
// Bills
bills: () => API.get('/bills'),
allBills: () => API.get('/bills?inactive=true'),
bill: (id) => API.get(`/bills/${id}`),
createBill: (data) => API.post('/bills', data),
updateBill: (id, d) => API.put(`/bills/${id}`, d),
deleteBill: (id) => API.delete(`/bills/${id}`),
// Payments
payments: (billId, y, m) => API.get(`/payments?bill_id=${billId}&year=${y}&month=${m}`),
quickPay: (data) => API.post('/payments/quick', data),
createPayment: (data) => API.post('/payments', data),
updatePayment: (id, data) => API.put(`/payments/${id}`, data),
deletePayment: (id) => API.delete(`/payments/${id}`),
// Categories
categories: () => API.get('/categories'),
createCategory: (name) => API.post('/categories', { name }),
updateCategory: (id, n) => API.put(`/categories/${id}`, { name: n }),
deleteCategory: (id) => API.delete(`/categories/${id}`),
// Settings
settings: () => API.get('/settings'),
saveSettings: (data) => API.put('/settings', data),
};

View File

@ -1,179 +0,0 @@
/* ── App bootstrap & routing ── */
const PAGES = {
tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
status: { container: 'page-status', init: (el) => StatusPage.init(el) },
};
let activePage = null;
let currentUser = null;
// ── Auth gate ──────────────────────────────────────────────────────────────
async function boot() {
let res;
try { res = await fetch('/api/auth/me'); }
catch { location.href = '/login.html'; return; }
if (!res.ok) { location.href = '/login.html'; return; }
const data = await res.json();
currentUser = data.user;
if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
document.getElementById('sidebar-username').textContent = currentUser.username;
if (data.single_user_mode) {
document.getElementById('sidebar-logout').style.display = 'none';
startApp();
return;
}
if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
if (currentUser.first_login) { showPrivacyNotice(); return; }
startApp();
}
// ── Password change overlay ────────────────────────────────────────────────
function showChangePasswordOverlay() {
document.getElementById('change-password-overlay').classList.remove('hidden');
document.getElementById('change-password-form').onsubmit = async (e) => {
e.preventDefault();
const np = document.getElementById('cp-new').value;
const cnf = document.getElementById('cp-confirm').value;
const err = document.getElementById('cp-error');
if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
err.textContent = '';
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: np }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error);
document.getElementById('change-password-overlay').classList.add('hidden');
currentUser.must_change_password = false;
currentUser.first_login ? showPrivacyNotice() : startApp();
} catch (ex) {
document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
}
};
}
// ── Privacy notice overlay ─────────────────────────────────────────────────
function showPrivacyNotice() {
document.getElementById('privacy-overlay').classList.remove('hidden');
document.getElementById('privacy-ack-btn').onclick = async () => {
await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
document.getElementById('privacy-overlay').classList.add('hidden');
currentUser.first_login = false;
startApp();
};
}
// ── Main app ───────────────────────────────────────────────────────────────
function startApp() {
document.getElementById('app').style.visibility = 'visible';
setupLogout();
setupNavLinks();
handleHash();
}
function setupLogout() {
document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
e.preventDefault();
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/login.html';
});
}
function setupNavLinks() {
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
});
window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
}
function handleHash() {
navigate(location.hash.replace('#', '') || 'tracker');
}
function navigate(page) {
if (!PAGES[page]) page = 'tracker';
document.querySelectorAll('.nav-link[data-page]').forEach(a => {
a.classList.toggle('active', a.dataset.page === page);
});
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
document.getElementById(PAGES[page].container).classList.add('active');
if (activePage !== page) {
activePage = page;
PAGES[page].init(document.getElementById(PAGES[page].container));
}
}
// ── Toast system ───────────────────────────────────────────────────────────
const TOAST_ICONS = {
success: '✓',
error: '✕',
warning: '⚠',
info: '',
};
let toastContainer = null;
function getToastContainer() {
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
document.body.appendChild(toastContainer);
}
return toastContainer;
}
function showToast(msg, type = 'info', duration = 3500) {
const container = getToastContainer();
const toast = document.createElement('div');
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-msg">${msg}</span>
<button class="toast-close" aria-label="Dismiss">&times;</button>
<div class="toast-bar"></div>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => toast.classList.add('toast-show'));
const dismiss = () => {
toast.classList.remove('toast-show');
toast.classList.add('toast-hide');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
};
const timer = setTimeout(dismiss, duration);
toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
}
// Hide app shell until auth check completes (prevents flash)
document.getElementById('app').style.visibility = 'hidden';
boot();

View File

@ -1,161 +0,0 @@
/* ── Bills management page ── */
const BillsPage = (() => {
let categories = [];
const CYCLE_LABELS = {
monthly: 'Monthly', quarterly: 'Quarterly',
annually: 'Annually', irregular: 'Irregular',
};
function fmt(amount) {
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function init(container) {
container.innerHTML = `<div class="loading">Loading...</div>`;
try {
[categories] = await Promise.all([API.categories()]);
render(container);
} catch (e) {
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
}
}
async function render(container) {
const bills = await API.allBills();
const active = bills.filter(b => b.active);
const inactive = bills.filter(b => !b.active);
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Bills</h1>
<button class="btn btn-primary" id="add-bill-btn">+ Add Bill</button>
</div>
<div class="bills-grid" id="bills-list">
${active.map(b => renderCard(b)).join('')}
${inactive.length ? `
<div style="margin-top:24px">
<div class="bucket-label" style="margin-bottom:12px">INACTIVE</div>
${inactive.map(b => renderCard(b, true)).join('')}
</div>` : ''}
${bills.length === 0 ? `<div class="empty-state"><p>No bills yet. Add your first bill!</p></div>` : ''}
</div>
`;
document.getElementById('add-bill-btn').onclick = () => openBillModal(null, () => render(container));
container.querySelectorAll('.btn-edit-bill').forEach(btn => {
btn.onclick = async () => {
const bill = await API.bill(btn.dataset.id);
openBillModal(bill, () => render(container));
};
});
container.querySelectorAll('.btn-toggle-bill').forEach(btn => {
btn.onclick = async () => {
const active = btn.dataset.active === '1';
if (!active || confirm('Deactivate this bill? It will be hidden from the tracker.')) {
await API.updateBill(btn.dataset.id, { active: active ? 0 : 1 });
render(container);
}
};
});
}
function renderCard(bill, inactive = false) {
const catName = categories.find(c => c.id === bill.category_id)?.name || '';
return `
<div class="bill-card ${inactive ? 'inactive' : ''}">
<div class="bill-card-info">
<div class="bill-card-name">${escHtml(bill.name)}</div>
<div class="bill-card-meta">
Day ${bill.due_day}
${catName ? ` · ${escHtml(catName)}` : ''}
· ${CYCLE_LABELS[bill.billing_cycle] || bill.billing_cycle}
${bill.autopay_enabled ? ' · <span style="color:var(--warning)">Autopay</span>' : ''}
</div>
</div>
<div class="bill-card-amount">${fmt(bill.expected_amount)}</div>
<div class="bill-card-actions">
<button class="btn btn-ghost btn-sm btn-edit-bill" data-id="${bill.id}">Edit</button>
<button class="btn btn-ghost btn-sm btn-toggle-bill"
data-id="${bill.id}" data-active="${bill.active}">
${bill.active ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
`;
}
function openBillModal(bill, onSave) {
const modal = document.getElementById('bill-modal');
const isNew = !bill;
document.getElementById('bill-modal-title').textContent = isNew ? 'Add Bill' : 'Edit Bill';
document.getElementById('bill-id').value = bill?.id || '';
document.getElementById('bill-name').value = bill?.name || '';
document.getElementById('bill-due-day').value = bill?.due_day || '';
document.getElementById('bill-expected').value = bill?.expected_amount || '';
document.getElementById('bill-interest-rate').value = bill?.interest_rate ?? '';
document.getElementById('bill-cycle').value = bill?.billing_cycle || 'monthly';
document.getElementById('bill-autopay').checked = !!bill?.autopay_enabled;
document.getElementById('bill-2fa').checked = !!bill?.has_2fa;
document.getElementById('bill-website').value = bill?.website || '';
document.getElementById('bill-username').value = bill?.username || '';
document.getElementById('bill-account-info').value = bill?.account_info || '';
document.getElementById('bill-notes').value = bill?.notes || '';
// Populate category select
const catSelect = document.getElementById('bill-category');
catSelect.innerHTML = '<option value="">— none —</option>' +
categories.map(c => `<option value="${c.id}" ${bill?.category_id == c.id ? 'selected' : ''}>${escHtml(c.name)}</option>`).join('');
modal.classList.remove('hidden');
const close = () => modal.classList.add('hidden');
document.getElementById('bill-modal-close').onclick = close;
document.getElementById('bill-modal-cancel').onclick = close;
modal.querySelector('.modal-overlay').onclick = close;
document.getElementById('bill-form').onsubmit = async (e) => {
e.preventDefault();
const data = {
name: document.getElementById('bill-name').value.trim(),
category_id: document.getElementById('bill-category').value || null,
due_day: parseInt(document.getElementById('bill-due-day').value, 10),
expected_amount: parseFloat(document.getElementById('bill-expected').value) || 0,
interest_rate: document.getElementById('bill-interest-rate').value === '' ? null : parseFloat(document.getElementById('bill-interest-rate').value),
billing_cycle: document.getElementById('bill-cycle').value,
autopay_enabled: document.getElementById('bill-autopay').checked,
has_2fa: document.getElementById('bill-2fa').checked,
website: document.getElementById('bill-website').value || null,
username: document.getElementById('bill-username').value || null,
account_info: document.getElementById('bill-account-info').value || null,
notes: document.getElementById('bill-notes').value || null,
};
try {
if (isNew) {
await API.createBill(data);
showToast('Bill added', 'success');
} else {
await API.updateBill(bill.id, data);
showToast('Bill updated', 'success');
}
close();
onSave();
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
}
return { init };
})();

View File

@ -1,110 +0,0 @@
/* ── Categories page ── */
const CategoriesPage = (() => {
function escHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function init(container) {
await render(container);
}
async function render(container) {
let cats;
try {
cats = await API.categories();
} catch (e) {
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
return;
}
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Categories</h1>
</div>
<form class="cat-add-form" id="cat-add-form">
<input type="text" id="cat-new-name" placeholder="New category name" style="max-width:280px">
<button type="submit" class="btn btn-primary">Add</button>
</form>
<div class="cat-list" id="cat-list">
${cats.map(c => renderItem(c)).join('')}
${cats.length === 0 ? `<div class="empty-state"><p>No categories yet.</p></div>` : ''}
</div>
`;
document.getElementById('cat-add-form').onsubmit = async (e) => {
e.preventDefault();
const name = document.getElementById('cat-new-name').value.trim();
if (!name) return;
try {
await API.createCategory(name);
document.getElementById('cat-new-name').value = '';
showToast('Category added', 'success');
render(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
container.querySelectorAll('.btn-delete-cat').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Delete this category? Bills using it will be uncategorized.')) return;
try {
await API.deleteCategory(btn.dataset.id);
showToast('Category deleted', 'success');
render(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
});
container.querySelectorAll('.cat-name-span').forEach(span => {
span.ondblclick = () => startRename(span, cats.find(c => c.id == span.dataset.id), container);
});
}
function renderItem(cat) {
return `
<div class="cat-item" data-cat-id="${cat.id}">
<span class="cat-name cat-name-span" data-id="${cat.id}" title="Double-click to rename">
${escHtml(cat.name)}
</span>
<button class="btn btn-ghost btn-sm btn-delete-cat" data-id="${cat.id}">Delete</button>
</div>
`;
}
function startRename(span, cat, container) {
const input = document.createElement('input');
input.type = 'text';
input.value = cat.name;
input.className = 'cat-name';
input.style.flex = '1';
span.replaceWith(input);
input.focus();
input.select();
async function commit() {
const name = input.value.trim();
if (!name || name === cat.name) { render(container); return; }
try {
await API.updateCategory(cat.id, name);
showToast('Renamed', 'success');
render(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
render(container);
}
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') render(container);
});
}
return { init };
})();

View File

@ -1,207 +0,0 @@
/* ── Settings page ── */
const SettingsPage = (() => {
async function init(container) {
let settings, notifPrefs;
try {
[settings, notifPrefs] = await Promise.all([
API.settings(),
fetch('/api/notifications/me').then(r => r.ok ? r.json() : null),
]);
} catch (e) {
container.innerHTML = `<div class="empty-state"><p>Failed to load settings.</p></div>`;
return;
}
const notifSection = buildNotifSection(notifPrefs);
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Settings</h1>
</div>
<div class="settings-section">
<h3>General</h3>
<div class="settings-row">
<label for="s-currency">Currency</label>
<select id="s-currency" style="max-width:120px">
<option value="USD" ${settings.currency === 'USD' ? 'selected' : ''}>USD $</option>
<option value="EUR" ${settings.currency === 'EUR' ? 'selected' : ''}>EUR </option>
<option value="GBP" ${settings.currency === 'GBP' ? 'selected' : ''}>GBP £</option>
<option value="CAD" ${settings.currency === 'CAD' ? 'selected' : ''}>CAD $</option>
</select>
</div>
<div class="settings-row">
<label for="s-date-format">Date Format</label>
<select id="s-date-format" style="max-width:160px">
<option value="MM/DD/YYYY" ${settings.date_format === 'MM/DD/YYYY' ? 'selected' : ''}>MM/DD/YYYY</option>
<option value="DD/MM/YYYY" ${settings.date_format === 'DD/MM/YYYY' ? 'selected' : ''}>DD/MM/YYYY</option>
<option value="YYYY-MM-DD" ${settings.date_format === 'YYYY-MM-DD' ? 'selected' : ''}>YYYY-MM-DD</option>
</select>
</div>
</div>
<div class="settings-section">
<h3>Billing Behavior</h3>
<div class="settings-row">
<label for="s-grace">Grace Period (days)</label>
<input type="number" id="s-grace" min="0" max="30" value="${settings.grace_period_days || 5}" style="max-width:80px">
</div>
</div>
<div class="settings-section">
<h3>Backup</h3>
<div class="settings-row">
<label>Auto Backup</label>
<label style="cursor:pointer">
<input type="checkbox" id="s-backup-enabled" ${settings.backup_enabled === 'true' ? 'checked' : ''}>
Enabled
</label>
</div>
<div class="settings-row">
<label for="s-backup-freq">Frequency (days)</label>
<input type="number" id="s-backup-freq" min="1" max="30" value="${settings.backup_frequency_days || 1}" style="max-width:80px">
</div>
<div class="settings-row">
<label for="s-backup-keep">Keep N Backups</label>
<input type="number" id="s-backup-keep" min="1" max="90" value="${settings.backup_keep_count || 14}" style="max-width:80px">
</div>
</div>
${notifSection}
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:8px">
<button class="btn btn-primary" id="save-settings-btn">Save Settings</button>
</div>
`;
document.getElementById('save-settings-btn').onclick = async () => {
const data = {
currency: document.getElementById('s-currency').value,
date_format: document.getElementById('s-date-format').value,
grace_period_days: document.getElementById('s-grace').value,
backup_enabled: document.getElementById('s-backup-enabled').checked ? 'true' : 'false',
backup_frequency_days: document.getElementById('s-backup-freq').value,
backup_keep_count: document.getElementById('s-backup-keep').value,
};
try {
await API.saveSettings(data);
showToast('Settings saved', 'success');
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
// Notification save (only wired if section exists)
const notifForm = document.getElementById('notif-user-form');
if (notifForm) {
notifForm.onsubmit = async (e) => {
e.preventDefault();
const btn = notifForm.querySelector('[type="submit"]');
btn.disabled = true;
try {
const res = await fetch('/api/notifications/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notification_email: document.getElementById('n-email').value.trim(),
notifications_enabled:document.getElementById('n-enabled').checked,
notify_3d: document.getElementById('n-3d').checked,
notify_1d: document.getElementById('n-1d').checked,
notify_due: document.getElementById('n-due').checked,
notify_overdue: document.getElementById('n-overdue').checked,
}),
});
if (!res.ok) throw new Error((await res.json()).error);
showToast('Notification preferences saved', 'success');
} catch (err) {
showToast('Error: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
};
// Toggle field visibility based on enabled checkbox
const toggle = () => {
const on = document.getElementById('n-enabled').checked;
document.getElementById('n-options').style.opacity = on ? '1' : '.4';
document.getElementById('n-options').style.pointerEvents = on ? '' : 'none';
};
document.getElementById('n-enabled').addEventListener('change', toggle);
toggle();
}
}
function buildNotifSection(p) {
if (!p) return ''; // API call failed, skip silently
if (!p.smtp_enabled) {
return `
<div class="settings-section">
<h3>Notifications</h3>
<p style="color:var(--text-muted);font-size:13px;">
Email notifications have not been configured by the admin.
</p>
</div>`;
}
if (!p.allow_user_config) {
return `
<div class="settings-section">
<h3>Notifications</h3>
<p style="color:var(--text-muted);font-size:13px;">
Email notifications are enabled and managed by the admin.
Your bills will generate reminders automatically.
</p>
</div>`;
}
// Full user-configurable notification section
const chk = (id, label, val) =>
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:normal;color:var(--text)">
<input type="checkbox" id="${id}" ${val ? 'checked' : ''}> ${label}
</label>`;
return `
<div class="settings-section">
<h3>Notifications</h3>
<form id="notif-user-form">
<div class="settings-row">
<label>Enable Notifications</label>
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-weight:normal;color:var(--text)">
<input type="checkbox" id="n-enabled" ${p.notifications_enabled ? 'checked' : ''}>
Send me email reminders
</label>
</div>
<div id="n-options">
<div class="settings-row" style="margin-top:6px">
<label for="n-email">Notification Email</label>
<input type="email" id="n-email" value="${escHtml(p.notification_email)}"
placeholder="your@email.com" style="max-width:280px">
</div>
<div class="settings-row" style="align-items:flex-start">
<label style="padding-top:2px">Remind me</label>
<div style="display:flex;flex-direction:column;gap:8px">
${chk('n-3d', '3 days before due', p.notify_3d)}
${chk('n-1d', '1 day before due', p.notify_1d)}
${chk('n-due', 'On the day it\'s due', p.notify_due)}
${chk('n-overdue', 'Daily while overdue', p.notify_overdue)}
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-top:12px">
<button type="submit" class="btn btn-ghost btn-sm">Save Notifications</button>
</div>
</form>
</div>`;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
return { init };
})();

View File

@ -1,103 +0,0 @@
/* ── Status page ── */
const StatusPage = (() => {
async function init(container) {
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Server Status</h1>
<button class="btn btn-ghost btn-sm" id="status-refresh">&#8635; Refresh</button>
</div>
<div id="status-body"><div class="loading">Loading...</div></div>
`;
document.getElementById('status-refresh').onclick = () => load(container);
load(container);
}
async function load(container) {
const body = document.getElementById('status-body');
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json();
body.innerHTML = render(d);
} catch (e) {
body.innerHTML = `<div class="empty-state"><p>Failed to load status: ${e.message}</p></div>`;
}
}
function fmtUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function fmtBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
}
function row(label, value, note = '') {
return `
<div class="stat-row">
<span class="stat-label">${label}</span>
<span class="stat-value">${value}${note ? `<span class="stat-note">${note}</span>` : ''}</span>
</div>`;
}
function render(d) {
const dbOk = d.database.status === 'connected';
return `
<div class="status-grid">
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Application</span>
<span class="status-dot dot-green" title="Online"></span>
</div>
${row('Version', `v${d.app.version}`)}
${row('Environment', d.app.environment)}
${row('Uptime', fmtUptime(d.app.uptime_seconds))}
</div>
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Runtime</span>
</div>
${row('Node.js', d.runtime.node_version)}
${row('Platform', `${d.runtime.platform} / ${d.runtime.arch}`)}
${row('Memory', `${d.runtime.memory_mb} MB`)}
</div>
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Database</span>
<span class="status-dot ${dbOk ? 'dot-green' : 'dot-red'}" title="${d.database.status}"></span>
</div>
${row('Status', dbOk ? 'Connected' : 'Error')}
${row('Size', fmtBytes(d.database.size_bytes))}
${row('File', `<code style="font-size:11px">${d.database.path}</code>`)}
</div>
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Statistics</span>
</div>
${row('Active Bills', d.stats.active_bills)}
${row('Total Payments', d.stats.total_payments)}
${row('Users', d.stats.users)}
${row('Active Sessions', d.stats.active_sessions)}
</div>
</div>
`;
}
return { init };
})();

View File

@ -1,424 +0,0 @@
/* ── Tracker page ── */
const TrackerPage = (() => {
let currentYear, currentMonth;
let trackerData = null;
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
const STATUS_META = {
paid: { label: 'Paid', cls: 'badge-paid' },
upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
late: { label: 'Late', cls: 'badge-late' },
missed: { label: 'Missed', cls: 'badge-missed' },
autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
};
function fmt(amount) {
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function fmtDate(dateStr) {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${parseInt(m)}/${parseInt(d)}/${y}`;
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function init(container) {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
render(container);
}
function render(container) {
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Tracker</h1>
<div class="month-nav">
<button class="btn btn-ghost btn-sm" id="prev-month">&#8592;</button>
<span class="month-label" id="month-label"></span>
<button class="btn btn-ghost btn-sm" id="next-month">&#8594;</button>
<button class="btn btn-ghost btn-sm" id="today-btn">Today</button>
</div>
</div>
<div class="summary-bar" id="summary-bar"></div>
<div id="tracker-body"><div class="loading">Loading...</div></div>
`;
document.getElementById('month-label').textContent =
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
document.getElementById('prev-month').onclick = () => navigate(-1, container);
document.getElementById('next-month').onclick = () => navigate(1, container);
document.getElementById('today-btn').onclick = () => {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
loadData(container);
};
loadData(container);
}
function navigate(delta, container) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
document.getElementById('month-label').textContent =
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
loadData(container);
}
async function loadData(container) {
document.getElementById('tracker-body').innerHTML = '<div class="loading">Loading...</div>';
try {
trackerData = await API.tracker(currentYear, currentMonth);
renderSummary(trackerData.summary);
renderRows(trackerData.rows, container);
} catch (e) {
document.getElementById('tracker-body').innerHTML =
`<div class="empty-state"><p>Failed to load tracker: ${e.message}</p></div>`;
}
}
function renderSummary(s) {
document.getElementById('summary-bar').innerHTML = `
<div class="summary-card">
<div class="label">Total Expected</div>
<div class="value">${fmt(s.total_expected)}</div>
</div>
<div class="summary-card success">
<div class="label">Total Paid</div>
<div class="value">${fmt(s.total_paid)}</div>
</div>
<div class="summary-card">
<div class="label">Remaining</div>
<div class="value">${fmt(s.remaining)}</div>
</div>
<div class="summary-card ${s.overdue > 0 ? 'danger' : ''}">
<div class="label">Overdue</div>
<div class="value">${fmt(s.overdue)}</div>
</div>
`;
}
function renderRows(rows, container) {
const body = document.getElementById('tracker-body');
if (!rows || rows.length === 0) {
body.innerHTML = `<div class="empty-state">
<p>No bills this month. <a href="#bills" class="btn-link">Add a bill</a></p>
</div>`;
return;
}
const first = rows.filter(r => r.bucket === '1st');
const second = rows.filter(r => r.bucket === '15th');
body.innerHTML = '';
if (first.length) body.appendChild(renderBucket('1st14th', first));
if (second.length) body.appendChild(renderBucket('15th31st', second));
// Attach event listeners after render
attachTableListeners(container);
}
function renderBucket(label, rows) {
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
const section = document.createElement('div');
section.className = 'bucket-section';
section.innerHTML = `
<div class="bucket-header">
<span class="bucket-label">${label}</span>
<span class="bucket-totals">${fmt(totalPaid)} / ${fmt(totalExpected)}</span>
</div>
<table class="tracker-table">
<thead>
<tr>
<th>Bill</th>
<th>Due</th>
<th>Expected</th>
<th>Amount Paid</th>
<th>Paid Date</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${rows.map(renderRow).join('')}
</tbody>
</table>
`;
return section;
}
function renderRow(row) {
const meta = STATUS_META[row.status] || STATUS_META.upcoming;
const rowCls = `row-${row.status}`;
const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
const isPaid = row.status === 'paid' || row.status === 'autodraft';
const autopayDot = row.autopay_enabled
? `<span class="autopay-dot" title="Autopay"></span>`
: '';
return `
<tr class="${rowCls}" data-bill-id="${row.id}">
<td>
<div class="td-inner bill-name-cell">
${autopayDot}
<div>
<div>${escHtml(row.name)}</div>
${row.category_name ? `<div class="bill-category">${escHtml(row.category_name)}</div>` : ''}
</div>
</div>
</td>
<td>
<div class="td-inner">${fmtDate(row.due_date)}</div>
</td>
<td>
<div class="td-inner amount-expected">${fmt(row.expected_amount)}</div>
</td>
<td>
<div class="td-inner">
<span class="editable-cell amount-cell ${paidAmt ? '' : 'empty'} ${mismatch ? 'amount-mismatch' : ''}"
data-bill-id="${row.id}" data-field="amount"
title="Click to edit payment amount">
${paidAmt || '—'}
</span>
</div>
</td>
<td>
<div class="td-inner">
<span class="editable-cell date-cell ${paidDate ? '' : 'empty'}"
data-bill-id="${row.id}" data-field="date"
title="Click to edit paid date">
${paidDate || '—'}
</span>
</div>
</td>
<td>
<div class="td-inner">
<span class="badge ${meta.cls}">${meta.label}</span>
</div>
</td>
<td>
<div class="action-cell">
${!isPaid ? `
<div class="quick-pay-group">
<input type="number" class="quick-pay-amount" min="0" step="0.01"
value="${row.expected_amount}"
data-bill-id="${row.id}"
title="Payment amount">
<button class="btn-pay btn-quick-pay" data-bill-id="${row.id}"
title="Mark paid today">Pay</button>
</div>` : ''}
${row.payments && row.payments.length > 0
? `<button class="btn-icon btn-edit-payment"
data-bill-id="${row.id}"
data-payment='${JSON.stringify(row.payments[0])}'
title="Edit payment">&#9998;</button>`
: ''}
</div>
</td>
</tr>
`;
}
function attachTableListeners(container) {
// Quick pay buttons — read amount from the sibling input
container.querySelectorAll('.btn-quick-pay').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const billId = btn.dataset.billId;
const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
btn.disabled = true;
try {
await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
showToast('Marked as paid', 'success');
loadData(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
btn.disabled = false;
}
};
});
// Edit payment buttons
container.querySelectorAll('.btn-edit-payment').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const payment = JSON.parse(btn.dataset.payment);
openPaymentModal(payment, () => loadData(container));
};
});
// Inline editable amount cells
container.querySelectorAll('.amount-cell').forEach(cell => {
cell.onclick = () => startInlineEdit(cell, 'number', container);
});
// Inline editable date cells
container.querySelectorAll('.date-cell').forEach(cell => {
cell.onclick = () => startInlineEdit(cell, 'date', container);
});
}
function startInlineEdit(cell, type, container) {
if (cell.querySelector('input')) return; // already editing
const billId = cell.dataset.billId;
const field = cell.dataset.field;
const row = trackerData?.rows?.find(r => r.id == billId);
if (!row) return;
let currentVal = '';
if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
if (field === 'date') currentVal = row.last_paid_date || '';
const input = document.createElement('input');
input.type = type === 'date' ? 'date' : 'number';
if (type === 'number') { input.step = '0.01'; input.min = '0'; }
input.value = currentVal;
input.style.cssText = 'width:100%;min-width:80px;';
const origText = cell.textContent.trim();
cell.textContent = '';
cell.appendChild(input);
cell.classList.remove('empty');
input.focus();
input.select();
async function commit() {
const val = input.value.trim();
if (!val) { cell.textContent = origText || '—'; cell.classList.add('empty'); return; }
try {
if (row.payments && row.payments.length > 0) {
const p = row.payments[0];
const update = {};
if (field === 'amount') update.amount = parseFloat(val);
if (field === 'date') update.paid_date = val;
await API.updatePayment(p.id, update);
} else {
// Create new payment
const paidDate = field === 'date' ? val : todayStr();
const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
}
showToast('Saved', 'success');
loadData(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
cell.textContent = origText || '—';
}
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') {
cell.textContent = origText || '—';
if (!origText) cell.classList.add('empty');
}
});
}
function openPaymentModal(payment, onSave) {
const modal = document.getElementById('payment-modal');
document.getElementById('payment-bill-id').value = payment.bill_id;
document.getElementById('payment-id').value = payment.id;
document.getElementById('payment-amount').value = payment.amount;
document.getElementById('payment-date').value = payment.paid_date;
document.getElementById('payment-method').value = payment.method || '';
document.getElementById('payment-notes').value = payment.notes || '';
document.getElementById('payment-modal-title').textContent = 'Edit Payment';
modal.classList.remove('hidden');
const close = () => modal.classList.add('hidden');
document.getElementById('payment-modal-close').onclick = close;
document.getElementById('payment-modal-cancel').onclick = close;
modal.querySelector('.modal-overlay').onclick = close;
// ✅ UPDATED DELETE LOGIC WITH CLEAR INTENT
document.getElementById('payment-delete').onclick = async () => {
const confirmDelete = confirm(
'Remove this payment?\n\n' +
'- The BILL will NOT be deleted\n' +
'- This will remove the payment record\n' +
'- The bill will become UNPAID\n\n' +
'Continue?'
);
if (!confirmDelete) return;
try {
await API.deletePayment(payment.id);
close();
showToast(
'Payment removed. Bill is now marked as unpaid.',
'success'
);
onSave(); // refresh tracker
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
};
// normal save logic untouched
document.getElementById('payment-form').onsubmit = async (e) => {
e.preventDefault();
const data = {
amount: parseFloat(document.getElementById('payment-amount').value),
paid_date: document.getElementById('payment-date').value,
method: document.getElementById('payment-method').value || null,
notes: document.getElementById('payment-notes').value || null,
};
try {
await API.updatePayment(payment.id, data);
close();
showToast('Payment saved', 'success');
onSave();
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
}
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init };
})();

View File

@ -1,174 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bill Tracker — Sign In</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1526;
--surface: #162236;
--surface-2: #1c2d47;
--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);
--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;
display: flex;
align-items: center;
justify-content: center;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(99,102,241,0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(34,211,165,0.04) 0%, transparent 50%);
}
.card {
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 12px;
padding: 36px 32px;
width: 100%;
max-width: 360px;
box-shadow: 0 24px 60px rgba(0,0,0,0.5), 0 0 0 1px var(--border);
}
.logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.logo-icon {
width: 36px; height: 36px;
background: var(--primary);
border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 17px; color: white;
box-shadow: 0 0 16px rgba(99,102,241,0.4);
}
.logo-text { font-size: 18px; font-weight: 700; color: var(--text); }
h2 { font-size: 15px; font-weight: 500; margin-bottom: 22px; color: var(--text-muted); }
.form-group { margin-bottom: 14px; }
label {
display: block;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .05em;
color: var(--text-muted);
margin-bottom: 6px;
}
input {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-size: 14px;
font-family: var(--font);
color: var(--text);
background: var(--surface-2);
transition: border-color .15s, box-shadow .15s;
}
input::placeholder { color: var(--text-faint); }
input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(99,102,241,0.2); }
.btn {
width: 100%;
padding: 10px;
border: none;
border-radius: var(--radius);
font-size: 14px;
font-family: var(--font);
font-weight: 600;
cursor: pointer;
background: var(--primary);
color: white;
margin-top: 6px;
transition: background .15s, box-shadow .15s;
}
.btn:hover:not(:disabled) { background: var(--primary-hover); box-shadow: 0 0 0 3px rgba(99,102,241,0.25); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.error {
background: var(--danger-light);
color: var(--danger);
border: 1px solid rgba(244,63,94,0.3);
border-radius: var(--radius);
padding: 9px 12px;
font-size: 13px;
margin-bottom: 14px;
display: none;
}
.error.show { display: block; }
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logo-icon">$</div>
<span class="logo-text">BillTracker</span>
</div>
<h2>Sign in to your account</h2>
<div class="error" id="error-msg"></div>
<form id="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" autocomplete="username" autocapitalize="none" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" autocomplete="current-password" required>
</div>
<button class="btn" type="submit" id="submit-btn">Sign In</button>
</form>
</div>
<script>
// If single-user mode is active, no login needed — go straight to the app
fetch('/api/auth/mode').then(r => r.json()).then(d => {
if (d.auth_mode === 'single') { location.href = '/'; return; }
});
// Redirect if already logged in
fetch('/api/auth/me').then(r => {
if (r.ok) return r.json().then(d => {
location.href = d.user.role === 'admin' ? '/admin.html' : '/';
});
});
document.getElementById('login-form').onsubmit = async (e) => {
e.preventDefault();
const btn = document.getElementById('submit-btn');
const err = document.getElementById('error-msg');
btn.disabled = true;
btn.textContent = 'Signing in…';
err.classList.remove('show');
try {
const res = 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,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
location.href = data.user.role === 'admin' ? '/admin.html' : '/';
} catch (ex) {
err.textContent = ex.message;
err.classList.add('show');
btn.disabled = false;
btn.textContent = 'Sign In';
}
};
</script>
</body>
</html>

View File

@ -1,766 +0,0 @@
<!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>

View File

@ -1,888 +0,0 @@
/* ── Reset & base ── */
*, *::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);
--success: #22d3a5;
--success-light: rgba(34,211,165,0.15);
--danger: #f43f5e;
--danger-light: rgba(244,63,94,0.15);
--warning: #fb923c;
--warning-light: rgba(251,146,60,0.15);
--info: #38bdf8;
--info-light: rgba(56,189,248,0.15);
--sidebar-w: 200px;
--header-h: 56px;
--radius: 6px;
--shadow: 0 1px 3px rgba(0,0,0,0.4), 0 0 0 1px var(--border);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
font-family: var(--font);
font-size: 14px;
background: var(--bg);
color: var(--text);
line-height: 1.5;
min-height: 100vh;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
/* ── Layout ── */
#app {
display: flex;
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-w);
background: #0a1120;
color: var(--text-muted);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 100;
border-right: 1px solid var(--border);
}
.logo {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 16px 16px;
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.logo-icon {
width: 30px; height: 30px;
background: var(--primary);
border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 15px; color: white;
box-shadow: 0 0 12px rgba(99,102,241,0.35);
}
.logo-text {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
.nav-links {
list-style: none;
padding: 0 8px;
}
.nav-links li { margin: 2px 0; }
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--text-muted);
text-decoration: none;
font-size: 13.5px;
transition: background .15s, color .15s;
}
.nav-link:hover { background: var(--surface-2); color: var(--text); }
.nav-link.active { background: var(--primary-light); color: var(--primary); }
.nav-icon { font-size: 12px; opacity: .8; }
.sidebar-footer {
margin-top: auto;
padding: 8px 8px 12px;
border-top: 1px solid var(--border);
}
#sidebar-username {
display: block;
font-size: 11px;
color: var(--text-faint);
padding: 4px 10px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-logout {
font-size: 12px !important;
opacity: .7;
}
.sidebar-logout:hover { opacity: 1; }
.main-content {
margin-left: var(--sidebar-w);
flex: 1;
min-height: 100vh;
padding: 24px;
}
.page { display: none; }
.page.active { display: block; }
/* ── Page header ── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
/* ── Summary cards ── */
.summary-bar {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.summary-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.summary-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: var(--border-strong);
}
.summary-card.danger::before { background: var(--danger); }
.summary-card.success::before { background: var(--success); }
.summary-card.warning::before { background: var(--warning); }
.summary-card .label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-muted);
margin-bottom: 6px;
}
.summary-card .value {
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.summary-card.danger .value { color: var(--danger); }
.summary-card.success .value { color: var(--success); }
.summary-card.warning .value { color: var(--warning); }
/* ── Month nav ── */
.month-nav {
display: flex;
align-items: center;
gap: 12px;
}
.month-nav .month-label {
font-size: 16px;
font-weight: 600;
min-width: 130px;
text-align: center;
color: var(--text);
}
/* ── Tracker table ── */
.bucket-section {
margin-bottom: 24px;
}
.bucket-header {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
margin-bottom: 8px;
border-bottom: 2px solid var(--border);
}
.bucket-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .07em;
color: var(--text-muted);
}
.bucket-totals {
font-size: 12px;
color: var(--text-faint);
margin-left: auto;
}
.tracker-table {
width: 100%;
border-collapse: collapse;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.tracker-table th {
background: var(--surface-2);
padding: 9px 12px;
text-align: left;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
white-space: nowrap;
}
.tracker-table td {
padding: 0;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.tracker-table tr:last-child td { border-bottom: none; }
.tracker-table tr:hover td { background: var(--surface-2); }
.tracker-table tr.row-paid:hover td { background: rgba(34,211,165,0.07); }
.tracker-table tr.row-late:hover td { background: rgba(251,146,60,0.07); }
.tracker-table tr.row-missed:hover td { background: rgba(244,63,94,0.07); }
.tracker-table tr.row-paid td { background: rgba(34,211,165,0.04); }
.tracker-table tr.row-late td { background: rgba(251,146,60,0.04); }
.tracker-table tr.row-missed td { background: rgba(244,63,94,0.04); }
.tracker-table tr.row-autodraft td { background: rgba(251,146,60,0.04); }
.td-inner {
padding: 10px 12px;
display: flex;
align-items: center;
gap: 6px;
min-height: 44px;
}
/* ── Status badge ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
letter-spacing: .02em;
}
.badge-paid { background: var(--success-light); color: var(--success); }
.badge-upcoming { background: var(--surface-3); color: var(--text-muted); }
.badge-due-soon { background: var(--warning-light); color: var(--warning); }
.badge-late { background: var(--warning-light); color: var(--warning); }
.badge-missed { background: var(--danger-light); color: var(--danger); }
.badge-autodraft { background: var(--warning-light); color: var(--warning); }
/* ── Inline editable cells ── */
.editable-cell {
cursor: pointer;
border-radius: 4px;
padding: 4px 6px;
min-width: 80px;
transition: background .1s;
}
.editable-cell:hover { background: var(--primary-light); }
.editable-cell.empty { color: var(--text-faint); }
.editable-cell input {
border: none;
outline: none;
background: transparent;
font-size: inherit;
font-family: inherit;
color: var(--text);
width: 100%;
min-width: 80px;
}
/* ── Bill name cell ── */
.bill-name-cell {
font-weight: 500;
color: var(--text);
}
.bill-category {
font-size: 11px;
color: var(--text-faint);
}
/* ── Quick pay group ── */
.quick-pay-group {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.quick-pay-group input[type="number"] {
width: 80px;
padding: 4px 8px;
font-size: 12px;
}
/* ── Action buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: none;
border-radius: var(--radius);
font-size: 13px;
font-family: var(--font);
font-weight: 500;
cursor: pointer;
transition: background .15s, color .15s, opacity .15s, box-shadow .15s;
white-space: nowrap;
}
.btn:disabled { opacity: .4; cursor: not-allowed; }
.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-success { background: var(--success); color: #0d1526; }
.btn-success:hover:not(:disabled) { background: #1ab890; }
.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); border-color: var(--border-strong); }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover:not(:disabled) { background: #e11d48; }
.btn-sm { padding: 4px 8px; font-size: 12px; }
.btn-pay {
background: var(--primary-light);
color: var(--primary);
border: 1px solid rgba(99,102,241,0.3);
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius);
}
.btn-pay:hover { background: var(--primary); color: white; border-color: var(--primary); }
.btn-icon {
background: transparent;
color: var(--text-faint);
border: none;
padding: 4px 6px;
font-size: 14px;
border-radius: 4px;
cursor: pointer;
transition: background .15s, color .15s;
}
.btn-icon:hover { background: var(--surface-2); color: var(--text); }
/* ── Action cell ── */
.action-cell {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 10px;
}
/* ── Amount display ── */
.amount-expected { color: var(--text-muted); font-size: 13px; }
.amount-actual { font-weight: 600; color: var(--text); }
.amount-mismatch { color: var(--warning); }
/* ── Bills management page ── */
.bills-grid {
display: grid;
gap: 10px;
}
.bill-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: var(--shadow);
transition: border-color .15s, background .15s;
}
.bill-card:hover { border-color: var(--primary); background: var(--surface-2); }
.bill-card.inactive { opacity: .45; }
.bill-card-info { flex: 1; min-width: 0; }
.bill-card-name { font-weight: 600; font-size: 14px; color: var(--text); }
.bill-card-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
.bill-card-amount {
font-size: 16px;
font-weight: 700;
color: var(--text);
min-width: 80px;
text-align: right;
}
.bill-card-actions { display: flex; gap: 6px; }
/* ── Categories page ── */
.cat-list { display: flex; flex-direction: column; gap: 8px; }
.cat-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
box-shadow: var(--shadow);
transition: border-color .15s;
}
.cat-item:hover { border-color: var(--border-strong); }
.cat-name { flex: 1; font-weight: 500; color: var(--text); }
.cat-add-form {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
/* ── Settings page ── */
.settings-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.settings-section h3 {
font-size: 14px;
font-weight: 700;
margin-bottom: 16px;
color: var(--text);
border-bottom: 1px solid var(--border);
padding-bottom: 8px;
}
.settings-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.settings-row:last-child { margin-bottom: 0; }
.settings-row label {
min-width: 180px;
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
/* ── Forms ── */
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.form-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
.form-group.full-width { grid-column: 1 / -1; }
.form-group.checkbox-group { flex-direction: row; flex-wrap: wrap; gap: 16px; }
.form-group.checkbox-group label {
display: flex; align-items: center; gap: 6px;
text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 500; color: var(--text);
cursor: pointer;
}
input[type="text"],
input[type="number"],
input[type="date"],
input[type="email"],
input[type="password"],
select,
textarea {
padding: 7px 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);
transition: border-color .15s, box-shadow .15s;
width: 100%;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99,102,241,0.2);
}
select option {
background: var(--surface-2);
color: var(--text);
}
input::placeholder { color: var(--text-faint); }
textarea::placeholder { color: var(--text-faint); }
textarea { resize: vertical; }
input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--primary);
cursor: pointer;
}
/* ── Modal ── */
.modal {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden { display: none; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.65);
backdrop-filter: blur(2px);
}
.modal-box {
position: relative;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: 10px;
width: 100%;
max-width: 560px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border);
padding: 24px;
}
.modal-box-sm { max-width: 380px; }
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-header h2 {
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.modal-close {
background: none;
border: none;
font-size: 22px;
color: var(--text-faint);
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color .15s;
}
.modal-close:hover { color: var(--text); }
.modal-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
/* ── Toast notifications ── */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 999;
display: flex;
flex-direction: column-reverse;
gap: 8px;
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 280px;
max-width: 380px;
padding: 12px 14px 12px 16px;
border-radius: var(--radius);
background: var(--surface-2);
border: 1px solid var(--border-strong);
border-left-width: 3px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04);
font-size: 13px;
font-weight: 500;
color: var(--text);
opacity: 0;
transform: translateX(20px) translateY(4px);
transition: opacity .25s ease, transform .25s ease;
pointer-events: auto;
}
.toast.show {
opacity: 1;
transform: translateX(0) translateY(0);
}
.toast.hide {
opacity: 0;
transform: translateX(20px) translateY(4px);
transition: opacity .2s ease, transform .2s ease;
}
.toast-icon {
font-size: 15px;
flex-shrink: 0;
margin-top: 1px;
}
.toast-body { flex: 1; line-height: 1.4; }
.toast.success { border-left-color: var(--success); }
.toast.success .toast-icon { color: var(--success); }
.toast.error { border-left-color: var(--danger); }
.toast.error .toast-icon { color: var(--danger); }
.toast.warning { border-left-color: var(--warning); }
.toast.warning .toast-icon { color: var(--warning); }
.toast.info { border-left-color: var(--info); }
.toast.info .toast-icon { color: var(--info); }
/* Legacy single #toast element (backwards compatibility) */
#toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--surface-2);
color: var(--text);
padding: 11px 16px 11px 18px;
border-radius: var(--radius);
border: 1px solid var(--border-strong);
border-left: 3px solid var(--primary);
font-size: 13px;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
z-index: 999;
opacity: 0;
transform: translateX(20px);
transition: opacity .25s, transform .25s;
pointer-events: none;
max-width: 360px;
}
#toast.show { opacity: 1; transform: translateX(0); }
#toast.success { border-left-color: var(--success); }
#toast.error { border-left-color: var(--danger); }
#toast.warning { border-left-color: var(--warning); }
/* ── Status page ── */
.status-page {
max-width: 700px;
margin: 0 auto;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 24px;
}
.status-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 14px;
}
.status-dot {
width: 11px;
height: 11px;
border-radius: 50%;
flex-shrink: 0;
position: relative;
}
.status-dot.green {
background: var(--success);
box-shadow: 0 0 0 0 rgba(34,211,165,0.4);
animation: pulse-green 2s infinite;
}
.status-dot.red {
background: var(--danger);
box-shadow: 0 0 0 0 rgba(244,63,94,0.4);
animation: pulse-red 2s infinite;
}
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(34,211,165,0.5); }
70% { box-shadow: 0 0 0 7px rgba(34,211,165,0); }
100% { box-shadow: 0 0 0 0 rgba(34,211,165,0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(244,63,94,0.5); }
70% { box-shadow: 0 0 0 7px rgba(244,63,94,0); }
100% { box-shadow: 0 0 0 0 rgba(244,63,94,0); }
}
.stat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.stat-row:last-child { border-bottom: none; }
.stat-row .stat-label { color: var(--text-muted); }
.stat-row .stat-value { font-weight: 600; color: var(--text); }
/* ── Misc ── */
.empty-state {
text-align: center;
padding: 48px 20px;
color: var(--text-faint);
}
.empty-state p { font-size: 14px; margin-top: 8px; }
.loading {
text-align: center;
padding: 32px;
color: var(--text-faint);
font-size: 13px;
}
.autopay-dot {
display: inline-block;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--warning);
margin-right: 2px;
vertical-align: middle;
}
.text-muted { color: var(--text-muted); }
.text-faint { color: var(--text-faint); }
.text-sm { font-size: 12px; }
.mt-1 { margin-top: 4px; }
.gap-8 { gap: 8px; }
/* ── Divider ── */
hr {
border: none;
border-top: 1px solid var(--border);
margin: 12px 0;
}
/* ── Code / monospace ── */
code {
background: var(--surface-3);
color: var(--info);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.sidebar { width: 60px; }
.logo-text, .nav-link span:not(.nav-icon) { display: none; }
.main-content { margin-left: 60px; padding: 16px; }
.summary-bar { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

View File

@ -1,234 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bill Tracker</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="app">
<nav class="sidebar">
<div class="logo">
<span class="logo-icon">$</span>
<span class="logo-text">BillTracker</span>
</div>
<ul class="nav-links">
<li><a href="#tracker" class="nav-link active" data-page="tracker">
<span class="nav-icon">&#9776;</span> Tracker
</a></li>
<li><a href="#bills" class="nav-link" data-page="bills">
<span class="nav-icon">&#9679;</span> Bills
</a></li>
<li><a href="#categories" class="nav-link" data-page="categories">
<span class="nav-icon">&#9670;</span> Categories
</a></li>
<li><a href="#settings" class="nav-link" data-page="settings">
<span class="nav-icon">&#9881;</span> Settings
</a></li>
<li><a href="#status" class="nav-link" data-page="status">
<span class="nav-icon">&#9210;</span> Status
</a></li>
</ul>
<div class="sidebar-footer">
<span id="sidebar-username"></span>
<a href="#" id="sidebar-logout" class="nav-link sidebar-logout">
<span class="nav-icon">&#8592;</span> Sign Out
</a>
</div>
</nav>
<main class="main-content">
<div id="page-tracker" class="page active"></div>
<div id="page-bills" class="page"></div>
<div id="page-categories" class="page"></div>
<div id="page-settings" class="page"></div>
<div id="page-status" class="page"></div>
</main>
</div>
<!-- Bill Modal -->
<div id="bill-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box">
<div class="modal-header">
<h2 id="bill-modal-title">Add Bill</h2>
<button class="modal-close" id="bill-modal-close">&times;</button>
</div>
<form id="bill-form" class="modal-form">
<input type="hidden" id="bill-id">
<div class="form-grid">
<div class="form-group">
<label for="bill-name">Name *</label>
<input type="text" id="bill-name" placeholder="e.g. Electricity" required>
</div>
<div class="form-group">
<label for="bill-category">Category</label>
<select id="bill-category">
<option value="">— none —</option>
</select>
</div>
<div class="form-group">
<label for="bill-due-day">Due Day (131) *</label>
<input type="number" id="bill-due-day" min="1" max="31" required>
</div>
<div class="form-group">
<label for="bill-expected">Expected Amount ($)</label>
<input type="number" id="bill-expected" min="0" step="0.01" placeholder="0.00">
</div>
<div class="form-group">
<label for="bill-interest-rate">Interest rate (APR %)</label>
<input type="number" id="bill-interest-rate" min="0" max="100" step="0.01" placeholder="Optional">
</div>
<div class="form-group">
<label for="bill-cycle">Billing Cycle</label>
<select id="bill-cycle">
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="annually">Annually</option>
<option value="irregular">Irregular</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="bill-autopay"> Autopay / Autodraft
</label>
<label>
<input type="checkbox" id="bill-2fa"> Has 2FA
</label>
</div>
<div class="form-group">
<label for="bill-website">Website</label>
<input type="text" id="bill-website" placeholder="https://...">
</div>
<div class="form-group">
<label for="bill-username">Username / Email</label>
<input type="text" id="bill-username">
</div>
<div class="form-group">
<label for="bill-account-info">Account Info</label>
<input type="text" id="bill-account-info" placeholder="Last 4 digits, account #...">
</div>
<div class="form-group full-width">
<label for="bill-notes">Notes</label>
<textarea id="bill-notes" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" id="bill-modal-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save Bill</button>
</div>
</form>
</div>
</div>
<!-- Payment Modal -->
<div id="payment-modal" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box modal-box-sm">
<div class="modal-header">
<h2 id="payment-modal-title">Edit Payment</h2>
<button class="modal-close" id="payment-modal-close">&times;</button>
</div>
<form id="payment-form" class="modal-form">
<input type="hidden" id="payment-bill-id">
<input type="hidden" id="payment-id">
<div class="form-group">
<label for="payment-amount">Amount ($) *</label>
<input type="number" id="payment-amount" min="0" step="0.01" required>
</div>
<div class="form-group">
<label for="payment-date">Paid Date *</label>
<input type="date" id="payment-date" required>
</div>
<div class="form-group">
<label for="payment-method">Method</label>
<select id="payment-method">
<option value=""></option>
<option value="bank">Bank Transfer</option>
<option value="card">Card</option>
<option value="autopay">Autopay</option>
<option value="check">Check</option>
<option value="cash">Cash</option>
</select>
</div>
<div class="form-group">
<label for="payment-notes">Notes</label>
<input type="text" id="payment-notes">
</div>
<!-- UPDATED FOOTER -->
<div class="modal-footer">
<button
type="button"
id="payment-delete"
class="btn btn-danger"
style="margin-right:auto"
title="Removes this payment record and marks the bill as unpaid. The bill itself is NOT deleted.">
Remove Payment
</button>
<button type="button" id="payment-modal-cancel" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
<!-- Privacy notice — shown on first login -->
<div id="privacy-overlay" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box modal-box-sm" style="text-align:left">
<div style="font-size:22px;margin-bottom:12px">&#128274;</div>
<h2 style="font-size:17px;margin-bottom:12px">Your data is private</h2>
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:14px">
The <strong>admin account</strong> on this system can only:
</p>
<ul style="margin:0 0 16px 20px;color:var(--text-muted);font-size:14px;line-height:1.9">
<li>Create new user accounts</li>
<li>Reset your password if you're locked out</li>
</ul>
<p style="color:var(--text-muted);font-size:14px;line-height:1.6;margin-bottom:20px">
The admin <strong>cannot</strong> view, edit, or access your bills,
payments, or any financial data — by design.
</p>
<button class="btn btn-primary" id="privacy-ack-btn" style="width:100%">Got it, take me to my tracker</button>
</div>
</div>
<!-- Password change — shown when must_change_password is set -->
<div id="change-password-overlay" class="modal hidden">
<div class="modal-overlay"></div>
<div class="modal-box modal-box-sm">
<h2 style="margin-bottom:6px">Change Your Password</h2>
<p style="color:var(--text-muted);font-size:13px;margin-bottom:18px">
Your password was reset by the admin. Please set a new one to continue.
</p>
<form id="change-password-form">
<div class="form-group" style="margin-bottom:12px">
<label for="cp-new">New Password</label>
<input type="password" id="cp-new" minlength="8" required placeholder="At least 8 characters">
</div>
<div class="form-group" style="margin-bottom:16px">
<label for="cp-confirm">Confirm Password</label>
<input type="password" id="cp-confirm" minlength="8" required>
</div>
<div id="cp-error" style="color:var(--danger);font-size:13px;margin-bottom:10px;min-height:18px"></div>
<button type="submit" class="btn btn-primary" style="width:100%">Set New Password</button>
</form>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/tracker.js"></script>
<script src="/js/bills.js"></script>
<script src="/js/categories.js"></script>
<script src="/js/settings.js"></script>
<script src="/js/status.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

View File

@ -1,51 +0,0 @@
/* Thin API client — all fetch calls go through here */
const API = {
async _fetch(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
// Add CSRF token header for state-changing methods
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
const name = 'bt_csrf_token';
const match = document.cookie.match(new RegExp(name + '=([^;]+)'));
if (match) opts.headers['x-csrf-token'] = match[1];
}
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
},
get: (path) => API._fetch('GET', path),
post: (path, body) => API._fetch('POST', path, body),
put: (path, body) => API._fetch('PUT', path, body),
delete: (path) => API._fetch('DELETE', path),
// Tracker
tracker: (year, month) => API.get(`/tracker?year=${year}&month=${month}`),
// Bills
bills: () => API.get('/bills'),
allBills: () => API.get('/bills?inactive=true'),
bill: (id) => API.get(`/bills/${id}`),
createBill: (data) => API.post('/bills', data),
updateBill: (id, d) => API.put(`/bills/${id}`, d),
deleteBill: (id) => API.delete(`/bills/${id}`),
// Payments
payments: (billId, y, m) => API.get(`/payments?bill_id=${billId}&year=${y}&month=${m}`),
quickPay: (data) => API.post('/payments/quick', data),
createPayment: (data) => API.post('/payments', data),
updatePayment: (id, data) => API.put(`/payments/${id}`, data),
deletePayment: (id) => API.delete(`/payments/${id}`),
// Categories
categories: () => API.get('/categories'),
createCategory: (name) => API.post('/categories', { name }),
updateCategory: (id, n) => API.put(`/categories/${id}`, { name: n }),
deleteCategory: (id) => API.delete(`/categories/${id}`),
// Settings
settings: () => API.get('/settings'),
saveSettings: (data) => API.put('/settings', data),
};

View File

@ -1,179 +0,0 @@
/* ── App bootstrap & routing ── */
const PAGES = {
tracker: { container: 'page-tracker', init: (el) => TrackerPage.init(el) },
bills: { container: 'page-bills', init: (el) => BillsPage.init(el) },
categories: { container: 'page-categories', init: (el) => CategoriesPage.init(el) },
settings: { container: 'page-settings', init: (el) => SettingsPage.init(el) },
status: { container: 'page-status', init: (el) => StatusPage.init(el) },
};
let activePage = null;
let currentUser = null;
// ── Auth gate ──────────────────────────────────────────────────────────────
async function boot() {
let res;
try { res = await fetch('/api/auth/me'); }
catch { location.href = '/login.html'; return; }
if (!res.ok) { location.href = '/login.html'; return; }
const data = await res.json();
currentUser = data.user;
if (currentUser.role === 'admin') { location.href = '/admin.html'; return; }
document.getElementById('sidebar-username').textContent = currentUser.username;
if (data.single_user_mode) {
document.getElementById('sidebar-logout').style.display = 'none';
startApp();
return;
}
if (currentUser.must_change_password) { showChangePasswordOverlay(); return; }
if (currentUser.first_login) { showPrivacyNotice(); return; }
startApp();
}
// ── Password change overlay ────────────────────────────────────────────────
function showChangePasswordOverlay() {
document.getElementById('change-password-overlay').classList.remove('hidden');
document.getElementById('change-password-form').onsubmit = async (e) => {
e.preventDefault();
const np = document.getElementById('cp-new').value;
const cnf = document.getElementById('cp-confirm').value;
const err = document.getElementById('cp-error');
if (np !== cnf) { err.textContent = 'Passwords do not match.'; return; }
if (np.length < 8) { err.textContent = 'Password must be at least 8 characters.'; return; }
err.textContent = '';
try {
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: np }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.error);
document.getElementById('change-password-overlay').classList.add('hidden');
currentUser.must_change_password = false;
currentUser.first_login ? showPrivacyNotice() : startApp();
} catch (ex) {
document.getElementById('cp-error').textContent = 'Error: ' + ex.message;
}
};
}
// ── Privacy notice overlay ─────────────────────────────────────────────────
function showPrivacyNotice() {
document.getElementById('privacy-overlay').classList.remove('hidden');
document.getElementById('privacy-ack-btn').onclick = async () => {
await fetch('/api/auth/acknowledge-privacy', { method: 'POST' });
document.getElementById('privacy-overlay').classList.add('hidden');
currentUser.first_login = false;
startApp();
};
}
// ── Main app ───────────────────────────────────────────────────────────────
function startApp() {
document.getElementById('app').style.visibility = 'visible';
setupLogout();
setupNavLinks();
handleHash();
}
function setupLogout() {
document.getElementById('sidebar-logout').addEventListener('click', async (e) => {
e.preventDefault();
await fetch('/api/auth/logout', { method: 'POST' });
location.href = '/login.html';
});
}
function setupNavLinks() {
document.querySelectorAll('.nav-link[data-page]').forEach(link => {
link.addEventListener('click', () => { activePage = null; navigate(link.dataset.page); });
});
window.addEventListener('hashchange', () => { activePage = null; handleHash(); });
}
function handleHash() {
navigate(location.hash.replace('#', '') || 'tracker');
}
function navigate(page) {
if (!PAGES[page]) page = 'tracker';
document.querySelectorAll('.nav-link[data-page]').forEach(a => {
a.classList.toggle('active', a.dataset.page === page);
});
document.querySelectorAll('.page').forEach(el => el.classList.remove('active'));
document.getElementById(PAGES[page].container).classList.add('active');
if (activePage !== page) {
activePage = page;
PAGES[page].init(document.getElementById(PAGES[page].container));
}
}
// ── Toast system ───────────────────────────────────────────────────────────
const TOAST_ICONS = {
success: '✓',
error: '✕',
warning: '⚠',
info: '',
};
let toastContainer = null;
function getToastContainer() {
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
document.body.appendChild(toastContainer);
}
return toastContainer;
}
function showToast(msg, type = 'info', duration = 3500) {
const container = getToastContainer();
const toast = document.createElement('div');
const icon = TOAST_ICONS[type] || TOAST_ICONS.info;
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<span class="toast-icon">${icon}</span>
<span class="toast-msg">${msg}</span>
<button class="toast-close" aria-label="Dismiss">&times;</button>
<div class="toast-bar"></div>
`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => toast.classList.add('toast-show'));
const dismiss = () => {
toast.classList.remove('toast-show');
toast.classList.add('toast-hide');
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
};
const timer = setTimeout(dismiss, duration);
toast.querySelector('.toast-close').onclick = () => { clearTimeout(timer); dismiss(); };
}
// Hide app shell until auth check completes (prevents flash)
document.getElementById('app').style.visibility = 'hidden';
boot();

View File

@ -1,161 +0,0 @@
/* ── Bills management page ── */
const BillsPage = (() => {
let categories = [];
const CYCLE_LABELS = {
monthly: 'Monthly', quarterly: 'Quarterly',
annually: 'Annually', irregular: 'Irregular',
};
function fmt(amount) {
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function init(container) {
container.innerHTML = `<div class="loading">Loading...</div>`;
try {
[categories] = await Promise.all([API.categories()]);
render(container);
} catch (e) {
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
}
}
async function render(container) {
const bills = await API.allBills();
const active = bills.filter(b => b.active);
const inactive = bills.filter(b => !b.active);
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Bills</h1>
<button class="btn btn-primary" id="add-bill-btn">+ Add Bill</button>
</div>
<div class="bills-grid" id="bills-list">
${active.map(b => renderCard(b)).join('')}
${inactive.length ? `
<div style="margin-top:24px">
<div class="bucket-label" style="margin-bottom:12px">INACTIVE</div>
${inactive.map(b => renderCard(b, true)).join('')}
</div>` : ''}
${bills.length === 0 ? `<div class="empty-state"><p>No bills yet. Add your first bill!</p></div>` : ''}
</div>
`;
document.getElementById('add-bill-btn').onclick = () => openBillModal(null, () => render(container));
container.querySelectorAll('.btn-edit-bill').forEach(btn => {
btn.onclick = async () => {
const bill = await API.bill(btn.dataset.id);
openBillModal(bill, () => render(container));
};
});
container.querySelectorAll('.btn-toggle-bill').forEach(btn => {
btn.onclick = async () => {
const active = btn.dataset.active === '1';
if (!active || confirm('Deactivate this bill? It will be hidden from the tracker.')) {
await API.updateBill(btn.dataset.id, { active: active ? 0 : 1 });
render(container);
}
};
});
}
function renderCard(bill, inactive = false) {
const catName = categories.find(c => c.id === bill.category_id)?.name || '';
return `
<div class="bill-card ${inactive ? 'inactive' : ''}">
<div class="bill-card-info">
<div class="bill-card-name">${escHtml(bill.name)}</div>
<div class="bill-card-meta">
Day ${bill.due_day}
${catName ? ` · ${escHtml(catName)}` : ''}
· ${CYCLE_LABELS[bill.billing_cycle] || bill.billing_cycle}
${bill.autopay_enabled ? ' · <span style="color:var(--warning)">Autopay</span>' : ''}
</div>
</div>
<div class="bill-card-amount">${fmt(bill.expected_amount)}</div>
<div class="bill-card-actions">
<button class="btn btn-ghost btn-sm btn-edit-bill" data-id="${bill.id}">Edit</button>
<button class="btn btn-ghost btn-sm btn-toggle-bill"
data-id="${bill.id}" data-active="${bill.active}">
${bill.active ? 'Deactivate' : 'Activate'}
</button>
</div>
</div>
`;
}
function openBillModal(bill, onSave) {
const modal = document.getElementById('bill-modal');
const isNew = !bill;
document.getElementById('bill-modal-title').textContent = isNew ? 'Add Bill' : 'Edit Bill';
document.getElementById('bill-id').value = bill?.id || '';
document.getElementById('bill-name').value = bill?.name || '';
document.getElementById('bill-due-day').value = bill?.due_day || '';
document.getElementById('bill-expected').value = bill?.expected_amount || '';
document.getElementById('bill-interest-rate').value = bill?.interest_rate ?? '';
document.getElementById('bill-cycle').value = bill?.billing_cycle || 'monthly';
document.getElementById('bill-autopay').checked = !!bill?.autopay_enabled;
document.getElementById('bill-2fa').checked = !!bill?.has_2fa;
document.getElementById('bill-website').value = bill?.website || '';
document.getElementById('bill-username').value = bill?.username || '';
document.getElementById('bill-account-info').value = bill?.account_info || '';
document.getElementById('bill-notes').value = bill?.notes || '';
// Populate category select
const catSelect = document.getElementById('bill-category');
catSelect.innerHTML = '<option value="">— none —</option>' +
categories.map(c => `<option value="${c.id}" ${bill?.category_id == c.id ? 'selected' : ''}>${escHtml(c.name)}</option>`).join('');
modal.classList.remove('hidden');
const close = () => modal.classList.add('hidden');
document.getElementById('bill-modal-close').onclick = close;
document.getElementById('bill-modal-cancel').onclick = close;
modal.querySelector('.modal-overlay').onclick = close;
document.getElementById('bill-form').onsubmit = async (e) => {
e.preventDefault();
const data = {
name: document.getElementById('bill-name').value.trim(),
category_id: document.getElementById('bill-category').value || null,
due_day: parseInt(document.getElementById('bill-due-day').value, 10),
expected_amount: parseFloat(document.getElementById('bill-expected').value) || 0,
interest_rate: document.getElementById('bill-interest-rate').value === '' ? null : parseFloat(document.getElementById('bill-interest-rate').value),
billing_cycle: document.getElementById('bill-cycle').value,
autopay_enabled: document.getElementById('bill-autopay').checked,
has_2fa: document.getElementById('bill-2fa').checked,
website: document.getElementById('bill-website').value || null,
username: document.getElementById('bill-username').value || null,
account_info: document.getElementById('bill-account-info').value || null,
notes: document.getElementById('bill-notes').value || null,
};
try {
if (isNew) {
await API.createBill(data);
showToast('Bill added', 'success');
} else {
await API.updateBill(bill.id, data);
showToast('Bill updated', 'success');
}
close();
onSave();
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
}
return { init };
})();

View File

@ -1,110 +0,0 @@
/* ── Categories page ── */
const CategoriesPage = (() => {
function escHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function init(container) {
await render(container);
}
async function render(container) {
let cats;
try {
cats = await API.categories();
} catch (e) {
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${e.message}</p></div>`;
return;
}
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Categories</h1>
</div>
<form class="cat-add-form" id="cat-add-form">
<input type="text" id="cat-new-name" placeholder="New category name" style="max-width:280px">
<button type="submit" class="btn btn-primary">Add</button>
</form>
<div class="cat-list" id="cat-list">
${cats.map(c => renderItem(c)).join('')}
${cats.length === 0 ? `<div class="empty-state"><p>No categories yet.</p></div>` : ''}
</div>
`;
document.getElementById('cat-add-form').onsubmit = async (e) => {
e.preventDefault();
const name = document.getElementById('cat-new-name').value.trim();
if (!name) return;
try {
await API.createCategory(name);
document.getElementById('cat-new-name').value = '';
showToast('Category added', 'success');
render(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
container.querySelectorAll('.btn-delete-cat').forEach(btn => {
btn.onclick = async () => {
if (!confirm('Delete this category? Bills using it will be uncategorized.')) return;
try {
await API.deleteCategory(btn.dataset.id);
showToast('Category deleted', 'success');
render(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
});
container.querySelectorAll('.cat-name-span').forEach(span => {
span.ondblclick = () => startRename(span, cats.find(c => c.id == span.dataset.id), container);
});
}
function renderItem(cat) {
return `
<div class="cat-item" data-cat-id="${cat.id}">
<span class="cat-name cat-name-span" data-id="${cat.id}" title="Double-click to rename">
${escHtml(cat.name)}
</span>
<button class="btn btn-ghost btn-sm btn-delete-cat" data-id="${cat.id}">Delete</button>
</div>
`;
}
function startRename(span, cat, container) {
const input = document.createElement('input');
input.type = 'text';
input.value = cat.name;
input.className = 'cat-name';
input.style.flex = '1';
span.replaceWith(input);
input.focus();
input.select();
async function commit() {
const name = input.value.trim();
if (!name || name === cat.name) { render(container); return; }
try {
await API.updateCategory(cat.id, name);
showToast('Renamed', 'success');
render(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
render(container);
}
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') render(container);
});
}
return { init };
})();

View File

@ -1,207 +0,0 @@
/* ── Settings page ── */
const SettingsPage = (() => {
async function init(container) {
let settings, notifPrefs;
try {
[settings, notifPrefs] = await Promise.all([
API.settings(),
fetch('/api/notifications/me').then(r => r.ok ? r.json() : null),
]);
} catch (e) {
container.innerHTML = `<div class="empty-state"><p>Failed to load settings.</p></div>`;
return;
}
const notifSection = buildNotifSection(notifPrefs);
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Settings</h1>
</div>
<div class="settings-section">
<h3>General</h3>
<div class="settings-row">
<label for="s-currency">Currency</label>
<select id="s-currency" style="max-width:120px">
<option value="USD" ${settings.currency === 'USD' ? 'selected' : ''}>USD $</option>
<option value="EUR" ${settings.currency === 'EUR' ? 'selected' : ''}>EUR </option>
<option value="GBP" ${settings.currency === 'GBP' ? 'selected' : ''}>GBP £</option>
<option value="CAD" ${settings.currency === 'CAD' ? 'selected' : ''}>CAD $</option>
</select>
</div>
<div class="settings-row">
<label for="s-date-format">Date Format</label>
<select id="s-date-format" style="max-width:160px">
<option value="MM/DD/YYYY" ${settings.date_format === 'MM/DD/YYYY' ? 'selected' : ''}>MM/DD/YYYY</option>
<option value="DD/MM/YYYY" ${settings.date_format === 'DD/MM/YYYY' ? 'selected' : ''}>DD/MM/YYYY</option>
<option value="YYYY-MM-DD" ${settings.date_format === 'YYYY-MM-DD' ? 'selected' : ''}>YYYY-MM-DD</option>
</select>
</div>
</div>
<div class="settings-section">
<h3>Billing Behavior</h3>
<div class="settings-row">
<label for="s-grace">Grace Period (days)</label>
<input type="number" id="s-grace" min="0" max="30" value="${settings.grace_period_days || 5}" style="max-width:80px">
</div>
</div>
<div class="settings-section">
<h3>Backup</h3>
<div class="settings-row">
<label>Auto Backup</label>
<label style="cursor:pointer">
<input type="checkbox" id="s-backup-enabled" ${settings.backup_enabled === 'true' ? 'checked' : ''}>
Enabled
</label>
</div>
<div class="settings-row">
<label for="s-backup-freq">Frequency (days)</label>
<input type="number" id="s-backup-freq" min="1" max="30" value="${settings.backup_frequency_days || 1}" style="max-width:80px">
</div>
<div class="settings-row">
<label for="s-backup-keep">Keep N Backups</label>
<input type="number" id="s-backup-keep" min="1" max="90" value="${settings.backup_keep_count || 14}" style="max-width:80px">
</div>
</div>
${notifSection}
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:8px">
<button class="btn btn-primary" id="save-settings-btn">Save Settings</button>
</div>
`;
document.getElementById('save-settings-btn').onclick = async () => {
const data = {
currency: document.getElementById('s-currency').value,
date_format: document.getElementById('s-date-format').value,
grace_period_days: document.getElementById('s-grace').value,
backup_enabled: document.getElementById('s-backup-enabled').checked ? 'true' : 'false',
backup_frequency_days: document.getElementById('s-backup-freq').value,
backup_keep_count: document.getElementById('s-backup-keep').value,
};
try {
await API.saveSettings(data);
showToast('Settings saved', 'success');
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
// Notification save (only wired if section exists)
const notifForm = document.getElementById('notif-user-form');
if (notifForm) {
notifForm.onsubmit = async (e) => {
e.preventDefault();
const btn = notifForm.querySelector('[type="submit"]');
btn.disabled = true;
try {
const res = await fetch('/api/notifications/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notification_email: document.getElementById('n-email').value.trim(),
notifications_enabled:document.getElementById('n-enabled').checked,
notify_3d: document.getElementById('n-3d').checked,
notify_1d: document.getElementById('n-1d').checked,
notify_due: document.getElementById('n-due').checked,
notify_overdue: document.getElementById('n-overdue').checked,
}),
});
if (!res.ok) throw new Error((await res.json()).error);
showToast('Notification preferences saved', 'success');
} catch (err) {
showToast('Error: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
};
// Toggle field visibility based on enabled checkbox
const toggle = () => {
const on = document.getElementById('n-enabled').checked;
document.getElementById('n-options').style.opacity = on ? '1' : '.4';
document.getElementById('n-options').style.pointerEvents = on ? '' : 'none';
};
document.getElementById('n-enabled').addEventListener('change', toggle);
toggle();
}
}
function buildNotifSection(p) {
if (!p) return ''; // API call failed, skip silently
if (!p.smtp_enabled) {
return `
<div class="settings-section">
<h3>Notifications</h3>
<p style="color:var(--text-muted);font-size:13px;">
Email notifications have not been configured by the admin.
</p>
</div>`;
}
if (!p.allow_user_config) {
return `
<div class="settings-section">
<h3>Notifications</h3>
<p style="color:var(--text-muted);font-size:13px;">
Email notifications are enabled and managed by the admin.
Your bills will generate reminders automatically.
</p>
</div>`;
}
// Full user-configurable notification section
const chk = (id, label, val) =>
`<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-weight:normal;color:var(--text)">
<input type="checkbox" id="${id}" ${val ? 'checked' : ''}> ${label}
</label>`;
return `
<div class="settings-section">
<h3>Notifications</h3>
<form id="notif-user-form">
<div class="settings-row">
<label>Enable Notifications</label>
<label style="cursor:pointer;display:flex;align-items:center;gap:6px;font-weight:normal;color:var(--text)">
<input type="checkbox" id="n-enabled" ${p.notifications_enabled ? 'checked' : ''}>
Send me email reminders
</label>
</div>
<div id="n-options">
<div class="settings-row" style="margin-top:6px">
<label for="n-email">Notification Email</label>
<input type="email" id="n-email" value="${escHtml(p.notification_email)}"
placeholder="your@email.com" style="max-width:280px">
</div>
<div class="settings-row" style="align-items:flex-start">
<label style="padding-top:2px">Remind me</label>
<div style="display:flex;flex-direction:column;gap:8px">
${chk('n-3d', '3 days before due', p.notify_3d)}
${chk('n-1d', '1 day before due', p.notify_1d)}
${chk('n-due', 'On the day it\'s due', p.notify_due)}
${chk('n-overdue', 'Daily while overdue', p.notify_overdue)}
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;margin-top:12px">
<button type="submit" class="btn btn-ghost btn-sm">Save Notifications</button>
</div>
</form>
</div>`;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
return { init };
})();

View File

@ -1,103 +0,0 @@
/* ── Status page ── */
const StatusPage = (() => {
async function init(container) {
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Server Status</h1>
<button class="btn btn-ghost btn-sm" id="status-refresh">&#8635; Refresh</button>
</div>
<div id="status-body"><div class="loading">Loading...</div></div>
`;
document.getElementById('status-refresh').onclick = () => load(container);
load(container);
}
async function load(container) {
const body = document.getElementById('status-body');
try {
const res = await fetch('/api/status');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json();
body.innerHTML = render(d);
} catch (e) {
body.innerHTML = `<div class="empty-state"><p>Failed to load status: ${e.message}</p></div>`;
}
}
function fmtUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function fmtBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
}
function row(label, value, note = '') {
return `
<div class="stat-row">
<span class="stat-label">${label}</span>
<span class="stat-value">${value}${note ? `<span class="stat-note">${note}</span>` : ''}</span>
</div>`;
}
function render(d) {
const dbOk = d.database.status === 'connected';
return `
<div class="status-grid">
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Application</span>
<span class="status-dot dot-green" title="Online"></span>
</div>
${row('Version', `v${d.app.version}`)}
${row('Environment', d.app.environment)}
${row('Uptime', fmtUptime(d.app.uptime_seconds))}
</div>
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Runtime</span>
</div>
${row('Node.js', d.runtime.node_version)}
${row('Platform', `${d.runtime.platform} / ${d.runtime.arch}`)}
${row('Memory', `${d.runtime.memory_mb} MB`)}
</div>
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Database</span>
<span class="status-dot ${dbOk ? 'dot-green' : 'dot-red'}" title="${d.database.status}"></span>
</div>
${row('Status', dbOk ? 'Connected' : 'Error')}
${row('Size', fmtBytes(d.database.size_bytes))}
${row('File', `<code style="font-size:11px">${d.database.path}</code>`)}
</div>
<div class="status-card">
<div class="status-card-header">
<span class="status-card-title">Statistics</span>
</div>
${row('Active Bills', d.stats.active_bills)}
${row('Total Payments', d.stats.total_payments)}
${row('Users', d.stats.users)}
${row('Active Sessions', d.stats.active_sessions)}
</div>
</div>
`;
}
return { init };
})();

View File

@ -1,424 +0,0 @@
/* ── Tracker page ── */
const TrackerPage = (() => {
let currentYear, currentMonth;
let trackerData = null;
const MONTH_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
const STATUS_META = {
paid: { label: 'Paid', cls: 'badge-paid' },
upcoming: { label: 'Upcoming', cls: 'badge-upcoming' },
due_soon: { label: 'Due Soon', cls: 'badge-due-soon' },
late: { label: 'Late', cls: 'badge-late' },
missed: { label: 'Missed', cls: 'badge-missed' },
autodraft: { label: 'Autodraft', cls: 'badge-autodraft' },
};
function fmt(amount) {
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function fmtDate(dateStr) {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${parseInt(m)}/${parseInt(d)}/${y}`;
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function init(container) {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
render(container);
}
function render(container) {
container.innerHTML = `
<div class="page-header">
<h1 class="page-title">Tracker</h1>
<div class="month-nav">
<button class="btn btn-ghost btn-sm" id="prev-month">&#8592;</button>
<span class="month-label" id="month-label"></span>
<button class="btn btn-ghost btn-sm" id="next-month">&#8594;</button>
<button class="btn btn-ghost btn-sm" id="today-btn">Today</button>
</div>
</div>
<div class="summary-bar" id="summary-bar"></div>
<div id="tracker-body"><div class="loading">Loading...</div></div>
`;
document.getElementById('month-label').textContent =
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
document.getElementById('prev-month').onclick = () => navigate(-1, container);
document.getElementById('next-month').onclick = () => navigate(1, container);
document.getElementById('today-btn').onclick = () => {
const now = new Date();
currentYear = now.getFullYear();
currentMonth = now.getMonth() + 1;
loadData(container);
};
loadData(container);
}
function navigate(delta, container) {
currentMonth += delta;
if (currentMonth > 12) { currentMonth = 1; currentYear++; }
if (currentMonth < 1) { currentMonth = 12; currentYear--; }
document.getElementById('month-label').textContent =
`${MONTH_NAMES[currentMonth - 1]} ${currentYear}`;
loadData(container);
}
async function loadData(container) {
document.getElementById('tracker-body').innerHTML = '<div class="loading">Loading...</div>';
try {
trackerData = await API.tracker(currentYear, currentMonth);
renderSummary(trackerData.summary);
renderRows(trackerData.rows, container);
} catch (e) {
document.getElementById('tracker-body').innerHTML =
`<div class="empty-state"><p>Failed to load tracker: ${e.message}</p></div>`;
}
}
function renderSummary(s) {
document.getElementById('summary-bar').innerHTML = `
<div class="summary-card">
<div class="label">Total Expected</div>
<div class="value">${fmt(s.total_expected)}</div>
</div>
<div class="summary-card success">
<div class="label">Total Paid</div>
<div class="value">${fmt(s.total_paid)}</div>
</div>
<div class="summary-card">
<div class="label">Remaining</div>
<div class="value">${fmt(s.remaining)}</div>
</div>
<div class="summary-card ${s.overdue > 0 ? 'danger' : ''}">
<div class="label">Overdue</div>
<div class="value">${fmt(s.overdue)}</div>
</div>
`;
}
function renderRows(rows, container) {
const body = document.getElementById('tracker-body');
if (!rows || rows.length === 0) {
body.innerHTML = `<div class="empty-state">
<p>No bills this month. <a href="#bills" class="btn-link">Add a bill</a></p>
</div>`;
return;
}
const first = rows.filter(r => r.bucket === '1st');
const second = rows.filter(r => r.bucket === '15th');
body.innerHTML = '';
if (first.length) body.appendChild(renderBucket('1st14th', first));
if (second.length) body.appendChild(renderBucket('15th31st', second));
// Attach event listeners after render
attachTableListeners(container);
}
function renderBucket(label, rows) {
const totalExpected = rows.reduce((s, r) => s + r.expected_amount, 0);
const totalPaid = rows.reduce((s, r) => s + r.total_paid, 0);
const section = document.createElement('div');
section.className = 'bucket-section';
section.innerHTML = `
<div class="bucket-header">
<span class="bucket-label">${label}</span>
<span class="bucket-totals">${fmt(totalPaid)} / ${fmt(totalExpected)}</span>
</div>
<table class="tracker-table">
<thead>
<tr>
<th>Bill</th>
<th>Due</th>
<th>Expected</th>
<th>Amount Paid</th>
<th>Paid Date</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${rows.map(renderRow).join('')}
</tbody>
</table>
`;
return section;
}
function renderRow(row) {
const meta = STATUS_META[row.status] || STATUS_META.upcoming;
const rowCls = `row-${row.status}`;
const paidDate = row.last_paid_date ? fmtDate(row.last_paid_date) : '';
const paidAmt = row.total_paid > 0 ? fmt(row.total_paid) : '';
const mismatch = row.total_paid > 0 && row.total_paid !== row.expected_amount;
const isPaid = row.status === 'paid' || row.status === 'autodraft';
const autopayDot = row.autopay_enabled
? `<span class="autopay-dot" title="Autopay"></span>`
: '';
return `
<tr class="${rowCls}" data-bill-id="${row.id}">
<td>
<div class="td-inner bill-name-cell">
${autopayDot}
<div>
<div>${escHtml(row.name)}</div>
${row.category_name ? `<div class="bill-category">${escHtml(row.category_name)}</div>` : ''}
</div>
</div>
</td>
<td>
<div class="td-inner">${fmtDate(row.due_date)}</div>
</td>
<td>
<div class="td-inner amount-expected">${fmt(row.expected_amount)}</div>
</td>
<td>
<div class="td-inner">
<span class="editable-cell amount-cell ${paidAmt ? '' : 'empty'} ${mismatch ? 'amount-mismatch' : ''}"
data-bill-id="${row.id}" data-field="amount"
title="Click to edit payment amount">
${paidAmt || '—'}
</span>
</div>
</td>
<td>
<div class="td-inner">
<span class="editable-cell date-cell ${paidDate ? '' : 'empty'}"
data-bill-id="${row.id}" data-field="date"
title="Click to edit paid date">
${paidDate || '—'}
</span>
</div>
</td>
<td>
<div class="td-inner">
<span class="badge ${meta.cls}">${meta.label}</span>
</div>
</td>
<td>
<div class="action-cell">
${!isPaid ? `
<div class="quick-pay-group">
<input type="number" class="quick-pay-amount" min="0" step="0.01"
value="${row.expected_amount}"
data-bill-id="${row.id}"
title="Payment amount">
<button class="btn-pay btn-quick-pay" data-bill-id="${row.id}"
title="Mark paid today">Pay</button>
</div>` : ''}
${row.payments && row.payments.length > 0
? `<button class="btn-icon btn-edit-payment"
data-bill-id="${row.id}"
data-payment='${JSON.stringify(row.payments[0])}'
title="Edit payment">&#9998;</button>`
: ''}
</div>
</td>
</tr>
`;
}
function attachTableListeners(container) {
// Quick pay buttons — read amount from the sibling input
container.querySelectorAll('.btn-quick-pay').forEach(btn => {
btn.onclick = async (e) => {
e.stopPropagation();
const billId = btn.dataset.billId;
const amtInput = btn.closest('.quick-pay-group')?.querySelector('.quick-pay-amount');
const amount = amtInput ? parseFloat(amtInput.value) || 0 : 0;
if (amount <= 0) { showToast('Enter a payment amount', 'error'); return; }
btn.disabled = true;
try {
await API.quickPay({ bill_id: billId, amount, paid_date: todayStr() });
showToast('Marked as paid', 'success');
loadData(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
btn.disabled = false;
}
};
});
// Edit payment buttons
container.querySelectorAll('.btn-edit-payment').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const payment = JSON.parse(btn.dataset.payment);
openPaymentModal(payment, () => loadData(container));
};
});
// Inline editable amount cells
container.querySelectorAll('.amount-cell').forEach(cell => {
cell.onclick = () => startInlineEdit(cell, 'number', container);
});
// Inline editable date cells
container.querySelectorAll('.date-cell').forEach(cell => {
cell.onclick = () => startInlineEdit(cell, 'date', container);
});
}
function startInlineEdit(cell, type, container) {
if (cell.querySelector('input')) return; // already editing
const billId = cell.dataset.billId;
const field = cell.dataset.field;
const row = trackerData?.rows?.find(r => r.id == billId);
if (!row) return;
let currentVal = '';
if (field === 'amount') currentVal = row.total_paid > 0 ? String(row.total_paid) : '';
if (field === 'date') currentVal = row.last_paid_date || '';
const input = document.createElement('input');
input.type = type === 'date' ? 'date' : 'number';
if (type === 'number') { input.step = '0.01'; input.min = '0'; }
input.value = currentVal;
input.style.cssText = 'width:100%;min-width:80px;';
const origText = cell.textContent.trim();
cell.textContent = '';
cell.appendChild(input);
cell.classList.remove('empty');
input.focus();
input.select();
async function commit() {
const val = input.value.trim();
if (!val) { cell.textContent = origText || '—'; cell.classList.add('empty'); return; }
try {
if (row.payments && row.payments.length > 0) {
const p = row.payments[0];
const update = {};
if (field === 'amount') update.amount = parseFloat(val);
if (field === 'date') update.paid_date = val;
await API.updatePayment(p.id, update);
} else {
// Create new payment
const paidDate = field === 'date' ? val : todayStr();
const amount = field === 'amount' ? parseFloat(val) : row.expected_amount;
await API.createPayment({ bill_id: billId, amount, paid_date: paidDate });
}
showToast('Saved', 'success');
loadData(container);
} catch (err) {
showToast('Error: ' + err.message, 'error');
cell.textContent = origText || '—';
}
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') input.blur();
if (e.key === 'Escape') {
cell.textContent = origText || '—';
if (!origText) cell.classList.add('empty');
}
});
}
function openPaymentModal(payment, onSave) {
const modal = document.getElementById('payment-modal');
document.getElementById('payment-bill-id').value = payment.bill_id;
document.getElementById('payment-id').value = payment.id;
document.getElementById('payment-amount').value = payment.amount;
document.getElementById('payment-date').value = payment.paid_date;
document.getElementById('payment-method').value = payment.method || '';
document.getElementById('payment-notes').value = payment.notes || '';
document.getElementById('payment-modal-title').textContent = 'Edit Payment';
modal.classList.remove('hidden');
const close = () => modal.classList.add('hidden');
document.getElementById('payment-modal-close').onclick = close;
document.getElementById('payment-modal-cancel').onclick = close;
modal.querySelector('.modal-overlay').onclick = close;
// ✅ UPDATED DELETE LOGIC WITH CLEAR INTENT
document.getElementById('payment-delete').onclick = async () => {
const confirmDelete = confirm(
'Remove this payment?\n\n' +
'- The BILL will NOT be deleted\n' +
'- This will remove the payment record\n' +
'- The bill will become UNPAID\n\n' +
'Continue?'
);
if (!confirmDelete) return;
try {
await API.deletePayment(payment.id);
close();
showToast(
'Payment removed. Bill is now marked as unpaid.',
'success'
);
onSave(); // refresh tracker
} catch (e) {
showToast('Error: ' + e.message, 'error');
}
};
// normal save logic untouched
document.getElementById('payment-form').onsubmit = async (e) => {
e.preventDefault();
const data = {
amount: parseFloat(document.getElementById('payment-amount').value),
paid_date: document.getElementById('payment-date').value,
method: document.getElementById('payment-method').value || null,
notes: document.getElementById('payment-notes').value || null,
};
try {
await API.updatePayment(payment.id, data);
close();
showToast('Payment saved', 'success');
onSave();
} catch (err) {
showToast('Error: ' + err.message, 'error');
}
};
}
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
return { init };
})();

View File

@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="0; url=/login">
<title>BillTracker — Sign In</title>
<script>
window.location.replace('/login');
</script>
</head>
<body>
<p><a href="/login">Continue to BillTracker sign in</a></p>
</body>
</html>

View File

@ -152,8 +152,10 @@ app.all('/api/*', (req, res) => {
}); });
}); });
// ── Legacy UI ("Remember When" mode) ───────────────────────────────────────── // ── Retired legacy UI ────────────────────────────────────────────────────────
app.use('/legacy', express.static(path.join(__dirname, 'legacy'))); app.all(['/legacy', '/legacy/*'], (req, res) => {
res.status(410).send('The legacy UI has been retired.');
});
// ── Modern UI (Vite build) ──────────────────────────────────────────────────── // ── Modern UI (Vite build) ────────────────────────────────────────────────────
app.get('/login.html', (req, res) => res.redirect(302, '/login')); app.get('/login.html', (req, res) => res.redirect(302, '/login'));