Compare commits

..

No commits in common. "2431231cc6dd757344eb8ab205c7a92241d99cad" and "02e05d664fb1622972e5e81de71e50d02be8f937" have entirely different histories.

14 changed files with 0 additions and 1681 deletions

13
.gitignore vendored
View file

@ -1,13 +0,0 @@
db.php
*.log
*.cache
/vendor/
*.env
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
*.swp
*.swo
*~

251
admin.php
View file

@ -1,251 +0,0 @@
<?php
// ─────────────────────────────────────────────
// admin.php · Admin Panel
// ─────────────────────────────────────────────
require_once 'db.php';
require_once 'auth.php';
requireAdmin();
require_once 'nav.php';
renderHead('Admin Panel');
renderTopBar('Admin Panel');
?>
<div class="page">
<!-- User stats bar -->
<div class="summary-row" id="statsRow" style="margin-top:8px">
<div class="summary-chip"><div class="chip-label">Total Users</div><div class="chip-val" id="statTotal"></div></div>
<div class="summary-chip"><div class="chip-label">Admins</div><div class="chip-val income" id="statAdmins"></div></div>
<div class="summary-chip"><div class="chip-label">Suspended</div><div class="chip-val expense" id="statSuspended"></div></div>
<div class="summary-chip"><div class="chip-label">Active</div><div class="chip-val" id="statActive"></div></div>
</div>
<!-- User list -->
<div class="section-label">Users</div>
<div class="card" id="userList">
<div class="empty"><div class="empty-icon"></div><p>Loading…</p></div>
</div>
<!-- System Settings -->
<div class="section-label">System Settings</div>
<div class="card" style="padding:16px 16px 20px">
<div class="form-group">
<label>Site Name</label>
<input type="text" id="siteName" placeholder="Finance Planner">
</div>
<div class="form-group" style="display:flex;align-items:center;gap:10px;margin-bottom:0">
<input type="checkbox" id="regEnabled"
style="width:18px;height:18px;accent-color:var(--accent);cursor:pointer;flex-shrink:0">
<label for="regEnabled" style="cursor:pointer;font-size:.88rem;color:var(--text)">
Allow new user registration
</label>
</div>
</div>
<!-- Donation Settings -->
<div class="section-label">Donation Settings</div>
<div class="card" style="padding:16px 16px 20px">
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:14px">
Configure donation links shown to all users on the Donate page and Settings. Leave blank to hide.
</p>
<div class="form-group">
<label>Message shown to users</label>
<input type="text" id="donationMsg"
placeholder="If you find this app useful, consider buying me a coffee!">
</div>
<div class="form-group">
<label>💳 PayPal link</label>
<input type="url" id="paypalLink" placeholder="https://paypal.me/yourname">
</div>
<div class="form-group">
<label> Ko-fi link</label>
<input type="url" id="kofiLink" placeholder="https://ko-fi.com/yourname">
</div>
<div class="form-group">
<label> Buy Me a Coffee link</label>
<input type="url" id="bmcLink" placeholder="https://buymeacoffee.com/yourname">
</div>
<button class="btn btn-primary" onclick="saveSettings()">Save All Settings</button>
<span id="settingsMsg" style="margin-left:10px;font-size:.82rem"></span>
</div>
</div>
<!-- Reset Password Modal -->
<div class="modal-backdrop" id="resetModal">
<div class="modal">
<div class="modal-header">
<h2>Reset Password</h2>
<button class="modal-close" onclick="closeResetModal()"></button>
</div>
<input type="hidden" id="resetUserId">
<p id="resetUserLabel" style="color:var(--text-dim);font-size:.85rem;margin-bottom:16px"></p>
<div class="form-group">
<label>New Password</label>
<input type="password" id="resetPw" placeholder="Minimum 6 characters">
</div>
<div class="form-group">
<label>Confirm Password</label>
<input type="password" id="resetPwConfirm" placeholder="Repeat password">
</div>
<button class="btn btn-primary btn-full" onclick="doReset()">Set New Password</button>
<div id="resetMsg" style="margin-top:10px;font-size:.85rem;text-align:center"></div>
</div>
</div>
<?php renderNav('admin'); ?>
<style>
.badge { display:inline-block;padding:2px 8px;border-radius:20px;font-size:.65rem;font-weight:700;vertical-align:middle; }
.badge-admin { background:rgba(58,180,242,.18);color:var(--accent);border:1px solid rgba(58,180,242,.3); }
.badge-suspended { background:rgba(248,113,113,.18);color:var(--red);border:1px solid rgba(248,113,113,.3); }
.user-actions { display:flex;gap:6px;flex-wrap:wrap;margin-top:6px; }
.user-actions .btn { padding:5px 10px;font-size:.75rem; }
@media(min-width:600px){ .user-actions { margin-top:0; } }
</style>
<script>
async function api(action, method='GET', body=null) {
const url = `api.php?action=${action}`;
const opt = { method };
if (body) { opt.headers={'Content-Type':'application/x-www-form-urlencoded'}; opt.body=body; }
const r = await fetch(url, opt);
return r.json();
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtDate(d) {
return new Date(d).toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'});
}
// ── Load users ──────────────────────────────
async function loadUsers() {
const res = await api('admin_list_users');
const el = document.getElementById('userList');
if (!res.ok || !res.data.length) {
el.innerHTML = '<div class="empty"><div class="empty-icon">👥</div><p>No users found.</p></div>';
return;
}
// Stats
const total = res.data.length;
const admins = res.data.filter(u => u.is_admin == 1).length;
const suspended = res.data.filter(u => u.is_suspended == 1).length;
document.getElementById('statTotal').textContent = total;
document.getElementById('statAdmins').textContent = admins;
document.getElementById('statSuspended').textContent = suspended;
document.getElementById('statActive').textContent = total - suspended;
const myId = <?= (int)currentUserId() ?>;
el.innerHTML = res.data.map(u => {
const isMe = u.id == myId;
const badges = [];
if (u.is_admin == 1) badges.push('<span class="badge badge-admin">Admin</span>');
if (u.is_suspended == 1) badges.push('<span class="badge badge-suspended">Suspended</span>');
return `<div class="card-row" style="flex-wrap:wrap;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:180px">
<div class="row-label">
${escHtml(u.display_name || u.username)}
${badges.join(' ')}
${isMe ? '<span style="color:var(--text-muted);font-size:.7rem">&nbsp;(you)</span>' : ''}
</div>
<div class="row-sub">
@${escHtml(u.username)} &nbsp;·&nbsp; Joined ${fmtDate(u.created_at)}
&nbsp;·&nbsp; ${u.account_count} account(s)
</div>
</div>
<div class="user-actions">
<button class="btn btn-ghost" onclick="openReset(${u.id},'${escHtml(u.username)}')">🔑 Reset PW</button>
${!isMe ? `<button class="btn btn-ghost" onclick="toggleAdmin(${u.id})">
${u.is_admin == 1 ? '⬇️ Revoke Admin' : '⬆️ Make Admin'}
</button>` : ''}
${!isMe ? `<button class="btn btn-ghost" onclick="toggleSuspend(${u.id})">
${u.is_suspended == 1 ? '✅ Unsuspend' : '🚫 Suspend'}
</button>` : ''}
${!isMe && u.is_admin != 1 ? `<button class="btn btn-danger" onclick="deleteUser(${u.id},'${escHtml(u.username)}')">🗑 Delete</button>` : ''}
</div>
</div>`;
}).join('');
}
async function toggleAdmin(id) {
if (!confirm('Toggle admin status for this user?')) return;
const res = await api('admin_toggle_admin', 'POST', new URLSearchParams({id}).toString());
if (res.ok) loadUsers(); else alert(res.error || 'Failed');
}
async function toggleSuspend(id) {
if (!confirm('Toggle suspension for this user?')) return;
const res = await api('admin_toggle_suspend', 'POST', new URLSearchParams({id}).toString());
if (res.ok) loadUsers(); else alert(res.error || 'Failed');
}
async function deleteUser(id, username) {
if (!confirm(`Permanently delete @${username} and ALL their data? This cannot be undone.`)) return;
const res = await api('admin_delete_user', 'POST', new URLSearchParams({id}).toString());
if (res.ok) loadUsers(); else alert(res.error || 'Failed');
}
// ── Reset password modal ─────────────────────
function openReset(id, username) {
document.getElementById('resetUserId').value = id;
document.getElementById('resetUserLabel').textContent = `Setting a new password for @${username}`;
document.getElementById('resetPw').value = '';
document.getElementById('resetPwConfirm').value = '';
document.getElementById('resetMsg').textContent = '';
document.getElementById('resetModal').classList.add('open');
setTimeout(() => document.getElementById('resetPw').focus(), 200);
}
function closeResetModal() { document.getElementById('resetModal').classList.remove('open'); }
document.getElementById('resetModal').addEventListener('click', e => {
if (e.target === document.getElementById('resetModal')) closeResetModal();
});
async function doReset() {
const id = document.getElementById('resetUserId').value;
const pw = document.getElementById('resetPw').value;
const pw2 = document.getElementById('resetPwConfirm').value;
const el = document.getElementById('resetMsg');
if (pw.length < 6) { el.style.color='var(--red)'; el.textContent='Min. 6 characters'; return; }
if (pw !== pw2) { el.style.color='var(--red)'; el.textContent='Passwords do not match'; return; }
const res = await api('admin_reset_password', 'POST', new URLSearchParams({id, password: pw}).toString());
el.style.color = res.ok ? 'var(--green)' : 'var(--red)';
el.textContent = res.ok ? '✓ Password updated' : ('✗ ' + (res.error || 'Failed'));
if (res.ok) setTimeout(closeResetModal, 1200);
}
// ── Settings ─────────────────────────────────
async function loadSettings() {
const res = await api('admin_get_settings');
if (!res.ok) return;
const s = res.data;
document.getElementById('siteName').value = s.site_name || '';
document.getElementById('regEnabled').checked = s.registration_enabled === '1';
document.getElementById('donationMsg').value = s.donation_message || '';
document.getElementById('paypalLink').value = s.paypal_link || '';
document.getElementById('kofiLink').value = s.kofi_link || '';
document.getElementById('bmcLink').value = s.buymeacoffee_link || '';
}
async function saveSettings() {
const fd = new URLSearchParams({
site_name: document.getElementById('siteName').value.trim(),
registration_enabled: document.getElementById('regEnabled').checked ? '1' : '0',
donation_message: document.getElementById('donationMsg').value.trim(),
paypal_link: document.getElementById('paypalLink').value.trim(),
kofi_link: document.getElementById('kofiLink').value.trim(),
buymeacoffee_link: document.getElementById('bmcLink').value.trim(),
});
const res = await api('admin_save_settings', 'POST', fd.toString());
const el = document.getElementById('settingsMsg');
el.style.color = res.ok ? 'var(--green)' : 'var(--red)';
el.textContent = res.ok ? '✓ Settings saved' : '✗ Failed to save';
if (res.ok) setTimeout(() => el.textContent = '', 3000);
}
loadUsers();
loadSettings();
</script>
</body>
</html>

453
api.php
View file

@ -1,453 +0,0 @@
<?php
// ─────────────────────────────────────────────
// api.php · JSON API — all AJAX endpoints
// ─────────────────────────────────────────────
require_once 'db.php';
require_once 'auth.php';
header('Content-Type: application/json');
function respond(bool $ok, $data = null, string $error = ''): void {
echo json_encode($ok
? ['ok' => true, 'data' => $data]
: ['ok' => false, 'error' => $error]
);
exit;
}
// All API calls require login
if (!currentUserId()) respond(false, null, 'Not authenticated');
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$uid = currentUserId();
try {
$db = getDB();
// ── ACCOUNTS ─────────────────────────────────────────────────────────────
if ($action === 'list_accounts') {
$st = $db->prepare("SELECT * FROM accounts WHERE user_id=? ORDER BY name");
$st->execute([$uid]);
respond(true, $st->fetchAll());
}
if ($action === 'add_account') {
$name = trim($_POST['name'] ?? '');
$currency = strtoupper(trim($_POST['currency'] ?? 'GBP'));
if (!$name) respond(false, null, 'Name required');
$st = $db->prepare("INSERT INTO accounts (user_id, name, currency) VALUES (?,?,?)");
$st->execute([$uid, $name, $currency]);
respond(true, ['id' => $db->lastInsertId()]);
}
if ($action === 'delete_account') {
$id = (int)($_POST['id'] ?? 0);
$db->prepare("DELETE FROM accounts WHERE id=? AND user_id=?")->execute([$id, $uid]);
respond(true);
}
// ── RECURRING TRANSACTIONS ────────────────────────────────────────────────
if ($action === 'list_transactions') {
$aid = (int)($_GET['account_id'] ?? 0);
if ($aid) {
$st = $db->prepare("SELECT t.*, a.currency FROM transactions t
JOIN accounts a ON a.id=t.account_id
WHERE t.account_id=? AND a.user_id=? ORDER BY t.name");
$st->execute([$aid, $uid]);
} else {
$st = $db->prepare("SELECT t.*, a.currency FROM transactions t
JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? ORDER BY t.name");
$st->execute([$uid]);
}
respond(true, $st->fetchAll());
}
if ($action === 'add_transaction') {
$st = $db->prepare("INSERT INTO transactions
(account_id,name,type,amount,start_date,frequency,interval_value,ends_on,notes,tags)
VALUES (?,?,?,?,?,?,?,?,?,?)");
$st->execute([
(int)$_POST['account_id'],
trim($_POST['name']),
$_POST['type'],
(float)$_POST['amount'],
$_POST['start_date'],
$_POST['frequency'],
(int)($_POST['interval_value'] ?? 1),
$_POST['ends_on'] ?: null,
trim($_POST['notes'] ?? '') ?: null,
trim($_POST['tags'] ?? '') ?: null,
]);
respond(true, ['id' => $db->lastInsertId()]);
}
if ($action === 'get_transaction') {
$st = $db->prepare("SELECT t.* FROM transactions t
JOIN accounts a ON a.id=t.account_id WHERE t.id=? AND a.user_id=?");
$st->execute([(int)($_GET['id']??0), $uid]);
respond(true, $st->fetch());
}
if ($action === 'update_transaction') {
$st = $db->prepare("UPDATE transactions SET
name=?,type=?,amount=?,start_date=?,frequency=?,interval_value=?,ends_on=?,notes=?,tags=?
WHERE id=?");
$st->execute([
trim($_POST['name']),
$_POST['type'],
(float)$_POST['amount'],
$_POST['start_date'],
$_POST['frequency'],
(int)($_POST['interval_value'] ?? 1),
$_POST['ends_on'] ?: null,
trim($_POST['notes'] ?? '') ?: null,
trim($_POST['tags'] ?? '') ?: null,
(int)$_POST['id'],
]);
respond(true);
}
if ($action === 'delete_transaction') {
$db->prepare("DELETE FROM transactions WHERE id=?")->execute([(int)($_POST['id']??0)]);
respond(true);
}
// ── BALANCES ─────────────────────────────────────────────────────────────
if ($action === 'list_balances') {
$aid = (int)($_GET['account_id'] ?? 0);
if ($aid) {
$st = $db->prepare("SELECT b.*,a.name AS account_name,a.currency FROM balances b
JOIN accounts a ON a.id=b.account_id WHERE b.account_id=? AND a.user_id=?
ORDER BY b.recorded_on DESC");
$st->execute([$aid, $uid]);
} else {
$st = $db->prepare("SELECT b.*,a.name AS account_name,a.currency FROM balances b
JOIN accounts a ON a.id=b.account_id WHERE a.user_id=?
ORDER BY b.recorded_on DESC");
$st->execute([$uid]);
}
respond(true, $st->fetchAll());
}
if ($action === 'add_balance') {
$st = $db->prepare("INSERT INTO balances (account_id,amount,type,recorded_on,notes) VALUES (?,?,?,?,?)");
$st->execute([
(int)$_POST['account_id'],
(float)$_POST['amount'],
$_POST['type'],
$_POST['recorded_on'],
trim($_POST['notes'] ?? '') ?: null,
]);
respond(true, ['id' => $db->lastInsertId()]);
}
if ($action === 'delete_balance') {
$db->prepare("DELETE FROM balances WHERE id=?")->execute([(int)($_POST['id']??0)]);
respond(true);
}
// ── ONE-OFF TRANSACTIONS ──────────────────────────────────────────────────
if ($action === 'list_oneoff') {
$aid = (int)($_GET['account_id'] ?? 0);
$limit = (int)($_GET['limit'] ?? 100);
if ($aid) {
$st = $db->prepare("SELECT o.*,a.currency FROM oneoff_transactions o
JOIN accounts a ON a.id=o.account_id
WHERE o.account_id=? AND a.user_id=? ORDER BY o.date DESC LIMIT $limit");
$st->execute([$aid, $uid]);
} else {
$st = $db->prepare("SELECT o.*,a.name AS account_name,a.currency FROM oneoff_transactions o
JOIN accounts a ON a.id=o.account_id WHERE a.user_id=? ORDER BY o.date DESC LIMIT $limit");
$st->execute([$uid]);
}
respond(true, $st->fetchAll());
}
if ($action === 'add_oneoff') {
$st = $db->prepare("INSERT INTO oneoff_transactions
(account_id,name,type,amount,date,notes,tags) VALUES (?,?,?,?,?,?,?)");
$st->execute([
(int)$_POST['account_id'],
trim($_POST['name']),
$_POST['type'],
(float)$_POST['amount'],
$_POST['date'],
trim($_POST['notes'] ?? '') ?: null,
trim($_POST['tags'] ?? '') ?: null,
]);
respond(true, ['id' => $db->lastInsertId()]);
}
if ($action === 'get_oneoff') {
$st = $db->prepare("SELECT o.* FROM oneoff_transactions o
JOIN accounts a ON a.id=o.account_id WHERE o.id=? AND a.user_id=?");
$st->execute([(int)($_GET['id']??0), $uid]);
respond(true, $st->fetch());
}
if ($action === 'update_oneoff') {
$st = $db->prepare("UPDATE oneoff_transactions SET
name=?,type=?,amount=?,date=?,notes=?,tags=? WHERE id=?");
$st->execute([
trim($_POST['name']),
$_POST['type'],
(float)$_POST['amount'],
$_POST['date'],
trim($_POST['notes'] ?? '') ?: null,
trim($_POST['tags'] ?? '') ?: null,
(int)$_POST['id'],
]);
respond(true);
}
if ($action === 'delete_oneoff') {
$db->prepare("DELETE FROM oneoff_transactions WHERE id=?")->execute([(int)($_POST['id']??0)]);
respond(true);
}
// ── DEBTS ─────────────────────────────────────────────────────────────────
if ($action === 'list_debts') {
$st = $db->prepare("SELECT * FROM debts WHERE user_id=? ORDER BY balance DESC");
$st->execute([$uid]);
respond(true, $st->fetchAll());
}
if ($action === 'add_debt') {
$st = $db->prepare("INSERT INTO debts
(user_id,name,type,balance,interest_rate,min_payment,due_day,credit_limit,notes,tags,opened_on,currency)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
$st->execute([
$uid,
trim($_POST['name']),
$_POST['dtype'],
(float)$_POST['balance'],
(float)($_POST['interest_rate'] ?? 0),
(float)($_POST['min_payment'] ?? 0),
$_POST['due_day'] ? (int)$_POST['due_day'] : null,
$_POST['credit_limit'] ? (float)$_POST['credit_limit'] : null,
trim($_POST['notes'] ?? '') ?: null,
trim($_POST['tags'] ?? '') ?: null,
$_POST['opened_on'] ?: null,
strtoupper(trim($_POST['currency'] ?? 'GBP')),
]);
respond(true, ['id' => $db->lastInsertId()]);
}
if ($action === 'get_debt') {
$st = $db->prepare("SELECT * FROM debts WHERE id=? AND user_id=?");
$st->execute([(int)($_GET['id']??0), $uid]);
respond(true, $st->fetch());
}
if ($action === 'update_debt') {
$st = $db->prepare("UPDATE debts SET
name=?,type=?,balance=?,interest_rate=?,min_payment=?,due_day=?,
credit_limit=?,notes=?,tags=?,opened_on=?,currency=?
WHERE id=? AND user_id=?");
$st->execute([
trim($_POST['name']),
$_POST['dtype'],
(float)$_POST['balance'],
(float)($_POST['interest_rate'] ?? 0),
(float)($_POST['min_payment'] ?? 0),
$_POST['due_day'] ? (int)$_POST['due_day'] : null,
$_POST['credit_limit'] ? (float)$_POST['credit_limit'] : null,
trim($_POST['notes'] ?? '') ?: null,
trim($_POST['tags'] ?? '') ?: null,
$_POST['opened_on'] ?: null,
strtoupper(trim($_POST['currency'] ?? 'GBP')),
(int)$_POST['id'],
$uid,
]);
respond(true);
}
if ($action === 'delete_debt') {
$db->prepare("DELETE FROM debts WHERE id=? AND user_id=?")->execute([(int)($_POST['id']??0), $uid]);
respond(true);
}
// ── TRENDS ────────────────────────────────────────────────────────────────
if ($action === 'trends') {
$aid = (int)($_GET['account_id'] ?? 0);
$start = new DateTime('first day of this month');
$end = (clone $start)->modify('+12 months');
if ($aid) {
$balSt = $db->prepare("SELECT amount, type FROM balances WHERE account_id=? ORDER BY recorded_on DESC LIMIT 1");
$balSt->execute([$aid]);
} else {
$balSt = $db->prepare("SELECT b.amount,b.type FROM balances b
JOIN accounts a ON a.id=b.account_id WHERE a.user_id=? ORDER BY b.recorded_on DESC LIMIT 1");
$balSt->execute([$uid]);
}
$balRow = $balSt->fetch();
$runningBalance = $balRow
? ($balRow['type'] === 'negative' ? -(float)$balRow['amount'] : (float)$balRow['amount'])
: 0.0;
if ($aid) {
$txSt = $db->prepare("SELECT t.* FROM transactions t
JOIN accounts a ON a.id=t.account_id WHERE t.account_id=? AND a.user_id=?");
$txSt->execute([$aid, $uid]);
} else {
$txSt = $db->prepare("SELECT t.* FROM transactions t
JOIN accounts a ON a.id=t.account_id WHERE a.user_id=?");
$txSt->execute([$uid]);
}
$txs = $txSt->fetchAll();
$points = [];
$cursor = clone $start;
while ($cursor <= $end) {
foreach ($txs as $tx) {
if (occursOn($tx, $cursor)) {
$signed = $tx['type'] === 'income' ? (float)$tx['amount'] : -(float)$tx['amount'];
$runningBalance += $signed;
}
}
$points[] = ['date' => $cursor->format('M Y'), 'balance' => round($runningBalance, 2)];
$cursor->modify('+1 month');
}
respond(true, $points);
}
// ── SUMMARY ───────────────────────────────────────────────────────────────
if ($action === 'summary') {
$aid = (int)($_GET['account_id'] ?? 0);
if ($aid) {
$iSt = $db->prepare("SELECT COALESCE(SUM(amount),0) FROM transactions WHERE account_id=? AND type='income'");
$iSt->execute([$aid]);
$eSt = $db->prepare("SELECT COALESCE(SUM(amount),0) FROM transactions WHERE account_id=? AND type='expense'");
$eSt->execute([$aid]);
} else {
$iSt = $db->prepare("SELECT COALESCE(SUM(t.amount),0) FROM transactions t
JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? AND t.type='income'");
$iSt->execute([$uid]);
$eSt = $db->prepare("SELECT COALESCE(SUM(t.amount),0) FROM transactions t
JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? AND t.type='expense'");
$eSt->execute([$uid]);
}
$dSt = $db->prepare("SELECT COALESCE(SUM(balance),0) FROM debts WHERE user_id=?");
$dSt->execute([$uid]);
respond(true, [
'monthly_income' => (float)$iSt->fetchColumn(),
'monthly_expense' => (float)$eSt->fetchColumn(),
'total_debt' => (float)$dSt->fetchColumn(),
]);
}
// ── USER SETTINGS ─────────────────────────────────────────────────────────
if ($action === 'change_password') {
$ok = changePassword($uid, $_POST['current'] ?? '', $_POST['new'] ?? '');
$ok ? respond(true) : respond(false, null, 'Current password is incorrect.');
}
if ($action === 'update_profile') {
$dn = trim($_POST['display_name'] ?? '');
$db->prepare("UPDATE users SET display_name=? WHERE id=?")->execute([$dn, $uid]);
$_SESSION['user']['display_name'] = $dn;
respond(true);
}
// ── ADMIN ─────────────────────────────────────────────────────────────────
// All admin actions re-verify admin status from DB for security
if ($action === 'admin_list_users') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$st = $db->query("
SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.created_at,
(SELECT COUNT(*) FROM accounts WHERE user_id = u.id) AS account_count
FROM users u
ORDER BY u.created_at ASC
");
respond(true, $st->fetchAll());
}
if ($action === 'admin_toggle_admin') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$id = (int)($_POST['id'] ?? 0);
if ($id === $uid) respond(false, null, 'You cannot change your own admin status.');
$db->prepare("UPDATE users SET is_admin = 1 - is_admin WHERE id = ?")->execute([$id]);
respond(true);
}
if ($action === 'admin_toggle_suspend') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$id = (int)($_POST['id'] ?? 0);
if ($id === $uid) respond(false, null, 'You cannot suspend yourself.');
$db->prepare("UPDATE users SET is_suspended = 1 - is_suspended WHERE id = ?")->execute([$id]);
respond(true);
}
if ($action === 'admin_delete_user') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$id = (int)($_POST['id'] ?? 0);
if ($id === $uid) respond(false, null, 'You cannot delete your own account.');
// Safety: never delete another admin
$st = $db->prepare("SELECT is_admin FROM users WHERE id = ?");
$st->execute([$id]);
$target = $st->fetch();
if (!$target) respond(false, null, 'User not found.');
if ($target['is_admin']) respond(false, null, 'Cannot delete another admin. Revoke their admin status first.');
$db->prepare("DELETE FROM users WHERE id = ?")->execute([$id]);
respond(true);
}
if ($action === 'admin_reset_password') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$id = (int)($_POST['id'] ?? 0);
$pw = $_POST['password'] ?? '';
if (strlen($pw) < 6) respond(false, null, 'Password must be at least 6 characters.');
$db->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($pw, PASSWORD_DEFAULT), $id]);
respond(true);
}
if ($action === 'admin_get_settings') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$st = $db->query("SELECT setting_key, setting_value FROM settings");
$out = [];
foreach ($st->fetchAll() as $row) {
$out[$row['setting_key']] = $row['setting_value'];
}
respond(true, $out);
}
if ($action === 'admin_save_settings') {
if (!isAdmin()) respond(false, null, 'Forbidden');
$allowed = ['site_name','registration_enabled','donation_message','paypal_link','kofi_link','buymeacoffee_link'];
$st = $db->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?,?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)");
foreach ($allowed as $k) {
$st->execute([$k, trim($_POST[$k] ?? '')]);
}
respond(true);
}
respond(false, null, "Unknown action: $action");
} catch (Exception $e) {
respond(false, null, $e->getMessage());
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function occursOn(array $tx, DateTime $date): bool {
$start = new DateTime($tx['start_date']);
if ($date < $start) return false;
if ($tx['ends_on'] && $date > new DateTime($tx['ends_on'])) return false;
$freq = $tx['frequency'];
$iv = (int)$tx['interval_value'];
if ($freq === 'monthly') {
return $date->format('d') === $start->format('d')
&& (($date->format('Ym') - $start->format('Ym')) % $iv === 0);
}
if ($freq === 'weekly') {
return ((int)$start->diff($date)->days % (7 * $iv)) === 0;
}
if ($freq === 'yearly') {
return $date->format('m-d') === $start->format('m-d');
}
return false;
}

113
auth.php
View file

@ -1,113 +0,0 @@
<?php
// ─────────────────────────────────────────────
// auth.php · Session-based authentication
// ─────────────────────────────────────────────
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
function currentUserId(): ?int {
return isset($_SESSION['user']['id']) ? (int)$_SESSION['user']['id'] : null;
}
function currentUser(): ?array {
return $_SESSION['user'] ?? null;
}
function isAdmin(): bool {
return (int)($_SESSION['user']['is_admin'] ?? 0) === 1;
}
function requireLogin(): void {
if (!currentUserId()) {
header('Location: login.php');
exit;
}
}
function requireAdmin(): void {
requireLogin();
if (!isAdmin()) {
http_response_code(403);
echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
. '<title>Access Denied · Finance Planner</title>'
. '<link rel="stylesheet" href="style.css"></head>'
. '<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px">'
. '<div style="text-align:center">'
. '<div style="font-size:3rem;margin-bottom:12px">🚫</div>'
. '<h2 style="margin-bottom:8px">Access Denied</h2>'
. '<p style="color:var(--text-dim);margin-bottom:20px">Admin privileges required.</p>'
. '<a href="index.php" style="color:var(--accent)">← Back to Dashboard</a>'
. '</div></body></html>';
exit;
}
}
/**
* Returns true on success, 'suspended' if account suspended, false on bad credentials.
*/
function attemptLogin(string $username, string $password): bool|string {
require_once __DIR__ . '/db.php';
$db = getDB();
$st = $db->prepare("SELECT * FROM users WHERE username = ? LIMIT 1");
$st->execute([trim($username)]);
$user = $st->fetch();
if (!$user) return false;
if ((int)($user['is_suspended'] ?? 0) === 1) return 'suspended';
if (!password_verify($password, $user['password_hash'])) return false;
$_SESSION['user'] = [
'id' => (int)$user['id'],
'username' => $user['username'],
'display_name' => $user['display_name'] ?? $user['username'],
'is_admin' => (int)($user['is_admin'] ?? 0),
];
return true;
}
function logout(): void {
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
function changePassword(int $userId, string $currentPassword, string $newPassword): bool {
require_once __DIR__ . '/db.php';
$db = getDB();
$st = $db->prepare("SELECT password_hash FROM users WHERE id = ?");
$st->execute([$userId]);
$row = $st->fetch();
if (!$row || !password_verify($currentPassword, $row['password_hash'])) return false;
$db->prepare("UPDATE users SET password_hash = ? WHERE id = ?")
->execute([password_hash($newPassword, PASSWORD_DEFAULT), $userId]);
return true;
}
// ── App settings (stored in `settings` table) ─────────────────────────────
function getSetting(string $key, string $default = ''): string {
try {
require_once __DIR__ . '/db.php';
$db = getDB();
$st = $db->prepare("SELECT setting_value FROM settings WHERE setting_key = ?");
$st->execute([$key]);
$row = $st->fetch();
return $row ? $row['setting_value'] : $default;
} catch (Exception $e) {
return $default;
}
}
function setSetting(string $key, string $value): void {
require_once __DIR__ . '/db.php';
$db = getDB();
$db->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?,?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)")
->execute([$key, $value]);
}

View file

@ -1,95 +0,0 @@
<?php
// ─────────────────────────────────────────────
// donate.php · Support / Donations
// ─────────────────────────────────────────────
require_once 'db.php';
require_once 'auth.php';
requireLogin();
require_once 'nav.php';
$siteName = getSetting('site_name', 'Finance Planner');
$donMsg = getSetting('donation_message', 'If you find this app useful, please consider supporting its development!');
$paypalLink = getSetting('paypal_link', '');
$kofiLink = getSetting('kofi_link', '');
$bmcLink = getSetting('buymeacoffee_link', '');
$hasAny = $paypalLink || $kofiLink || $bmcLink;
renderHead('Donate');
renderTopBar('Support Us');
?>
<div class="page">
<div style="text-align:center;padding:28px 16px 20px">
<div style="font-size:3rem;margin-bottom:12px">❤️</div>
<h2 style="font-size:1.1rem;margin-bottom:10px">Support <?= htmlspecialchars($siteName) ?></h2>
<p style="color:var(--text-dim);font-size:.88rem;max-width:340px;margin:0 auto;line-height:1.6">
<?= htmlspecialchars($donMsg) ?>
</p>
</div>
<?php if (!$hasAny): ?>
<div class="card" style="padding:32px;text-align:center">
<div style="font-size:2rem;margin-bottom:10px">🔧</div>
<p style="color:var(--text-muted);font-size:.85rem">
No donation options have been set up yet.<br>
<?php if (isAdmin()): ?>
<a href="admin.php" style="color:var(--accent)">Configure them in the Admin Panel </a>
<?php else: ?>
Check back soon!
<?php endif; ?>
</p>
</div>
<?php else: ?>
<div class="section-label">Choose a Platform</div>
<div class="card">
<?php if ($paypalLink): ?>
<div class="card-row">
<div style="font-size:1.8rem;margin-right:14px;width:36px;text-align:center">💳</div>
<div style="flex:1">
<div class="row-label">PayPal</div>
<div class="row-sub">Fast, secure, widely accepted</div>
</div>
<a href="<?= htmlspecialchars($paypalLink) ?>" target="_blank" rel="noopener noreferrer"
class="btn btn-primary" style="text-decoration:none;flex-shrink:0">Donate</a>
</div>
<?php endif; ?>
<?php if ($kofiLink): ?>
<div class="card-row">
<div style="font-size:1.8rem;margin-right:14px;width:36px;text-align:center"></div>
<div style="flex:1">
<div class="row-label">Ko-fi</div>
<div class="row-sub">Buy me a coffee, no account needed</div>
</div>
<a href="<?= htmlspecialchars($kofiLink) ?>" target="_blank" rel="noopener noreferrer"
class="btn btn-primary" style="text-decoration:none;flex-shrink:0">Donate</a>
</div>
<?php endif; ?>
<?php if ($bmcLink): ?>
<div class="card-row">
<div style="font-size:1.8rem;margin-right:14px;width:36px;text-align:center"></div>
<div style="flex:1">
<div class="row-label">Buy Me a Coffee</div>
<div class="row-sub">One-time or monthly support</div>
</div>
<a href="<?= htmlspecialchars($bmcLink) ?>" target="_blank" rel="noopener noreferrer"
class="btn btn-primary" style="text-decoration:none;flex-shrink:0">Donate</a>
</div>
<?php endif; ?>
</div>
<p style="text-align:center;color:var(--text-muted);font-size:.78rem;padding:20px 0">
Every donation, however small, keeps this project going. Thank you! ❤️
</p>
<?php endif; ?>
</div>
<?php renderNav('settings'); ?>
</body>
</html>

View file

@ -1,48 +0,0 @@
#!/bin/bash
# ─────────────────────────────────────────────
# git-cron-setup.sh · Optional: auto-sync
# on a schedule using cron.
#
# Adds a cron job that runs git-sync.sh
# every night at 2am.
# ─────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SYNC_SCRIPT="$SCRIPT_DIR/git-sync.sh"
LOG_FILE="$SCRIPT_DIR/git-sync.log"
CRON_SCHEDULE="0 2 * * *" # 2:00am daily — change if you like
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Auto-Sync Cron Setup ║"
echo "╚══════════════════════════════════════════╝"
echo ""
# Check the sync script exists
if [ ! -f "$SYNC_SCRIPT" ]; then
echo "$SYNC_SCRIPT not found. Please upload git-sync.sh first."
exit 1
fi
chmod +x "$SYNC_SCRIPT"
CRON_LINE="$CRON_SCHEDULE $SYNC_SCRIPT >> $LOG_FILE 2>&1"
# Don't add duplicate
if crontab -l 2>/dev/null | grep -qF "$SYNC_SCRIPT"; then
echo " Cron job already exists — no changes made."
else
(crontab -l 2>/dev/null; echo "$CRON_LINE") | crontab -
echo "✅ Cron job added:"
echo " $CRON_LINE"
fi
echo ""
echo "Current cron jobs:"
crontab -l
echo ""
echo "Logs will be written to: $LOG_FILE"
echo ""
echo "To remove the cron job: crontab -e (then delete the line)"
echo "To run a sync right now: $SYNC_SCRIPT"
echo ""

View file

@ -1,98 +0,0 @@
#!/bin/bash
# ─────────────────────────────────────────────
# git-setup.sh · Run ONCE to initialise the
# local repo and connect it to Gitea.
# ─────────────────────────────────────────────
set -e # stop on any error
REMOTE_URL="https://git.cdb-online.co.uk/Computers_Dont_Byte/FinancePlanner"
BRANCH="main"
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Finance Planner — Git Setup ║"
echo "╚══════════════════════════════════════════╝"
echo ""
# ── Move to the directory this script lives in
cd "$(dirname "$0")"
echo "📁 Working directory: $(pwd)"
echo ""
# ── Ask for identity (used in commit messages)
read -p "Your name (for git commits): " GIT_NAME
read -p "Your email (for git commits): " GIT_EMAIL
echo ""
# ── Initialise git if not already done
if [ ! -d ".git" ]; then
git init
git checkout -b "$BRANCH" 2>/dev/null || git branch -m "$BRANCH"
echo "✅ Git repository initialised."
else
echo " Git already initialised — skipping."
fi
# ── Set local identity
git config user.name "$GIT_NAME"
git config user.email "$GIT_EMAIL"
# ── Set remote
if git remote get-url origin &>/dev/null; then
git remote set-url origin "$REMOTE_URL"
echo "✅ Remote 'origin' updated."
else
git remote add origin "$REMOTE_URL"
echo "✅ Remote 'origin' added."
fi
echo ""
echo "Remote → $REMOTE_URL"
echo ""
# ── Copy in the .gitignore if not present
if [ ! -f ".gitignore" ]; then
cat > .gitignore << 'GITIGNORE'
db.php
*.log
*.cache
/vendor/
*.env
.DS_Store
Thumbs.db
desktop.ini
.vscode/
.idea/
*.swp
*.swo
*~
GITIGNORE
echo "✅ .gitignore created (db.php is excluded to protect credentials)."
fi
# ── Initial commit
git add -A
git status --short
echo ""
git commit -m "Initial commit — Finance Planner setup" || echo " Nothing new to commit."
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Setup complete! ║"
echo "╚══════════════════════════════════════════╝"
echo ""
echo "Next steps:"
echo ""
echo " Option A — HTTPS (username + password / token):"
echo " git push -u origin $BRANCH"
echo " (You will be prompted for your Gitea credentials)"
echo ""
echo " Option B — SSH (no password prompts):"
echo " ssh-keygen -t ed25519 -C \"$GIT_EMAIL\""
echo " cat ~/.ssh/id_ed25519.pub # paste this into Gitea → Settings → SSH Keys"
echo " git remote set-url origin git@git.cdb-online.co.uk:Computers_Dont_Byte/FinancePlanner.git"
echo " git push -u origin $BRANCH"
echo ""
echo " Then run ./git-sync.sh any time you want to push changes."
echo ""

View file

@ -1,50 +0,0 @@
#!/bin/bash
# ─────────────────────────────────────────────
# git-sync.sh · Commit & push all changes
# to Gitea. Run this whenever you update files.
#
# Usage:
# ./git-sync.sh (auto message)
# ./git-sync.sh "My message" (custom message)
# ─────────────────────────────────────────────
set -e
BRANCH="main"
cd "$(dirname "$0")"
# ── Check we're in a git repo
if [ ! -d ".git" ]; then
echo "❌ No git repo found. Run ./git-setup.sh first."
exit 1
fi
# ── Check there's anything to sync
if git diff --quiet && git diff --cached --quiet && [ -z "$(git status --porcelain)" ]; then
echo "✅ Nothing to sync — everything is already up to date."
exit 0
fi
# ── Commit message
if [ -n "$1" ]; then
MSG="$1"
else
MSG="Sync: $(date '+%Y-%m-%d %H:%M:%S')"
fi
echo ""
echo "📦 Files changed:"
git status --short
echo ""
git add -A
git commit -m "$MSG"
echo ""
echo "⬆️ Pushing to origin/$BRANCH"
git push origin "$BRANCH"
echo ""
echo "✅ Synced successfully at $(date '+%H:%M:%S')"
echo ""

View file

@ -1,20 +0,0 @@
# ── Sensitive config ──────────────────────────
db.php
# ── PHP / server ──────────────────────────────
*.log
*.cache
/vendor/
*.env
# ── OS junk ───────────────────────────────────
.DS_Store
Thumbs.db
desktop.ini
# ── Editor files ──────────────────────────────
.vscode/
.idea/
*.swp
*.swo
*~

View file

@ -1,95 +0,0 @@
<?php
// ─────────────────────────────────────────────
// login.php · Login & logout
// ─────────────────────────────────────────────
require_once __DIR__ . '/auth.php';
// Already logged in
if (currentUserId()) { header('Location: index.php'); exit; }
// Logout action
if (($_GET['action'] ?? '') === 'logout') {
logout();
header('Location: login.php');
exit;
}
$siteName = getSetting('site_name', 'Finance Planner');
$registrationEnabled = getSetting('registration_enabled', '1') === '1';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (!$username || !$password) {
$error = 'Please enter your username and password.';
} else {
$result = attemptLogin($username, $password);
if ($result === true) {
header('Location: index.php');
exit;
} elseif ($result === 'suspended') {
$error = 'Your account has been suspended. Please contact the administrator.';
} else {
$error = 'Incorrect username or password.';
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Login · <?= htmlspecialchars($siteName) ?></title>
<link rel="stylesheet" href="style.css">
<style>
.auth-wrap { display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px; }
.auth-card {
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:36px 30px;max-width:380px;width:100%;
}
.auth-logo { font-size:2.5rem;text-align:center;margin-bottom:10px; }
.auth-card h1 { font-size:1.2rem;text-align:center;margin-bottom:6px; }
.auth-sub { color:var(--text-dim);font-size:.8rem;text-align:center;margin-bottom:24px; }
.auth-footer { color:var(--text-muted);font-size:.8rem;text-align:center;margin-top:16px; }
.auth-footer a { color:var(--accent);text-decoration:none; }
.auth-hint { color:var(--text-muted);font-size:.75rem;text-align:center;margin-top:10px; }
.auth-hint code { background:var(--surface2);padding:1px 5px;border-radius:4px;color:var(--accent); }
</style>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-logo">💷</div>
<h1><?= htmlspecialchars($siteName) ?></h1>
<p class="auth-sub">Sign in to your account</p>
<?php if ($error): ?>
<div class="alert alert-err"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="login.php">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
placeholder="admin" autocomplete="username" autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password"
placeholder="••••••••" autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary btn-full">Sign In</button>
</form>
<?php if ($registrationEnabled): ?>
<div class="auth-footer">No account? <a href="register.php">Create one </a></div>
<?php endif; ?>
<p class="auth-hint">Default: <code>admin</code> / <code>admin</code></p>
</div>
</div>
</body>
</html>

85
nav.php
View file

@ -1,85 +0,0 @@
<?php
// ─────────────────────────────────────────────
// nav.php · Shared navigation partial
// ─────────────────────────────────────────────
require_once __DIR__ . '/auth.php';
function renderHead(string $title): void {
$site = getSetting('site_name', 'Finance Planner');
echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$title} · {$site}</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
HTML;
}
function renderTopBar(string $title, bool $addBtn = false, string $addId = ''): void {
$user = currentUser();
$name = htmlspecialchars($user['display_name'] ?? '');
$btn = $addBtn ? "<button class='topbar-action' id='{$addId}' title='Add'>&#43;</button>" : '';
$initial = $name ? strtoupper(substr(strip_tags($name), 0, 1)) : '';
$avatar = $initial
? "<a href='settings.php' class='topbar-avatar' title='Settings'>{$initial}</a>"
: '';
echo <<<HTML
<header class="topbar">
{$avatar}
<h1>{$title}</h1>
{$btn}
</header>
HTML;
}
function renderNav(string $active): void {
$pages = [
'trends' => ['index.php', 'Trends', trendIcon()],
'transactions' => ['transactions.php', 'Recurring', txIcon()],
'oneoff' => ['oneoff.php', 'One-off', oneoffIcon()],
'debts' => ['debts.php', 'Debts', debtIcon()],
'balances' => ['balances.php', 'Balances', balIcon()],
'accounts' => ['accounts.php', 'Accounts', acctIcon()],
'settings' => ['settings.php', 'Settings', settingsIcon()],
];
if (isAdmin()) {
$pages['admin'] = ['admin.php', 'Admin', adminIcon()];
}
echo '<nav class="nav">';
foreach ($pages as $key => [$href, $label, $icon]) {
$cls = $key === $active ? ' class="active"' : '';
echo "<a href='{$href}'{$cls}>{$icon}<span>{$label}</span></a>";
}
echo '</nav>';
}
// ── Icons ─────────────────────────────────────
function trendIcon(): string {
return '<svg viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
}
function txIcon(): string {
return '<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>';
}
function balIcon(): string {
return '<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>';
}
function acctIcon(): string {
return '<svg viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>';
}
function debtIcon(): string {
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
}
function oneoffIcon(): string {
return '<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';
}
function settingsIcon(): string {
return '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
}
function adminIcon(): string {
return '<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
}

View file

@ -1,136 +0,0 @@
<?php
// ─────────────────────────────────────────────
// register.php · New user registration
// ─────────────────────────────────────────────
require_once __DIR__ . '/auth.php';
if (currentUserId()) { header('Location: index.php'); exit; }
$siteName = getSetting('site_name', 'Finance Planner');
$registrationEnabled = getSetting('registration_enabled', '1') === '1';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$registrationEnabled) {
$error = 'Registration is currently disabled.';
} else {
$username = trim($_POST['username'] ?? '');
$displayName = trim($_POST['display_name'] ?? '');
$password = $_POST['password'] ?? '';
$confirm = $_POST['confirm_password'] ?? '';
if (!$username || !$password) {
$error = 'Username and password are required.';
} elseif (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $username)) {
$error = 'Username must be 330 characters: letters, numbers, underscores only.';
} elseif (strlen($password) < 6) {
$error = 'Password must be at least 6 characters.';
} elseif ($password !== $confirm) {
$error = 'Passwords do not match.';
} else {
try {
require_once __DIR__ . '/db.php';
$db = getDB();
$st = $db->prepare("SELECT id FROM users WHERE username = ?");
$st->execute([$username]);
if ($st->fetch()) {
$error = 'That username is already taken. Please choose another.';
} else {
$dn = $displayName ?: $username;
$hash = password_hash($password, PASSWORD_DEFAULT);
$db->prepare("INSERT INTO users (username, password_hash, display_name) VALUES (?,?,?)")
->execute([$username, $hash, $dn]);
$newId = (int)$db->lastInsertId();
// Give them a default account to get started
$db->prepare("INSERT INTO accounts (user_id, name, currency) VALUES (?,?,?)")
->execute([$newId, 'Main Account', 'GBP']);
// Auto-login
$_SESSION['user'] = [
'id' => $newId,
'username' => $username,
'display_name' => $dn,
'is_admin' => 0,
];
header('Location: index.php');
exit;
}
} catch (Exception $e) {
$error = 'Registration failed. Please try again.';
}
}
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Register · <?= htmlspecialchars($siteName) ?></title>
<link rel="stylesheet" href="style.css">
<style>
.auth-wrap { display:flex;align-items:center;justify-content:center;min-height:100vh;padding:24px; }
.auth-card {
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:36px 30px;max-width:380px;width:100%;
}
.auth-logo { font-size:2.5rem;text-align:center;margin-bottom:10px; }
.auth-card h1 { font-size:1.2rem;text-align:center;margin-bottom:6px; }
.auth-sub { color:var(--text-dim);font-size:.8rem;text-align:center;margin-bottom:24px; }
.auth-footer { color:var(--text-muted);font-size:.8rem;text-align:center;margin-top:16px; }
.auth-footer a { color:var(--accent);text-decoration:none; }
.field-hint { font-size:.7rem;color:var(--text-muted);margin-top:3px; }
</style>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-logo">💷</div>
<h1><?= htmlspecialchars($siteName) ?></h1>
<p class="auth-sub">Create your free account</p>
<?php if (!$registrationEnabled): ?>
<div class="alert alert-err">
Registration is currently disabled.<br>
Please contact the site administrator.
</div>
<div class="auth-footer"><a href="login.php"> Back to Login</a></div>
<?php else: ?>
<?php if ($error): ?>
<div class="alert alert-err"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST" action="register.php">
<div class="form-group">
<label>Username <span style="color:var(--red)">*</span></label>
<input type="text" name="username"
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
placeholder="e.g. jane_doe" autocomplete="username" autofocus required>
<div class="field-hint">330 characters · letters, numbers, underscores</div>
</div>
<div class="form-group">
<label>Display Name <span style="color:var(--text-muted)">(optional)</span></label>
<input type="text" name="display_name"
value="<?= htmlspecialchars($_POST['display_name'] ?? '') ?>"
placeholder="e.g. Jane">
</div>
<div class="form-group">
<label>Password <span style="color:var(--red)">*</span></label>
<input type="password" name="password"
placeholder="Minimum 6 characters" autocomplete="new-password" required>
</div>
<div class="form-group">
<label>Confirm Password <span style="color:var(--red)">*</span></label>
<input type="password" name="confirm_password"
placeholder="Repeat password" autocomplete="new-password" required>
</div>
<button type="submit" class="btn btn-primary btn-full">Create Account</button>
</form>
<div class="auth-footer">Already have an account? <a href="login.php">Sign in </a></div>
<?php endif; ?>
</div>
</div>
</body>
</html>

View file

@ -1,155 +0,0 @@
<?php
// ─────────────────────────────────────────────
// settings.php · User settings
// ─────────────────────────────────────────────
require_once 'db.php';
require_once 'auth.php';
requireLogin();
require_once 'nav.php';
$user = currentUser();
renderHead('Settings');
renderTopBar('Settings');
?>
<div class="page">
<!-- Profile ─────────────────────────────── -->
<div class="section-label">Profile</div>
<div class="card" style="padding:16px 16px 20px">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:18px">
<div style="width:52px;height:52px;border-radius:50%;background:var(--accent);
display:flex;align-items:center;justify-content:center;
font-size:1.4rem;font-weight:700;color:#fff;flex-shrink:0">
<?= strtoupper(substr($user['display_name'] ?? $user['username'], 0, 1)) ?>
</div>
<div>
<div style="font-weight:600"><?= htmlspecialchars($user['display_name'] ?? $user['username']) ?></div>
<div style="font-size:.8rem;color:var(--text-dim)">@<?= htmlspecialchars($user['username']) ?></div>
<?php if (isAdmin()): ?>
<span class="badge badge-admin" style="margin-left:0;margin-top:4px">Admin</span>
<?php endif; ?>
</div>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" id="displayName"
value="<?= htmlspecialchars($user['display_name'] ?? '') ?>"
placeholder="Your name">
</div>
<button class="btn btn-primary" onclick="saveProfile()">Save Name</button>
<span id="profileMsg" style="margin-left:10px;font-size:.82rem"></span>
</div>
<!-- Change Password ──────────────────────── -->
<div class="section-label">Change Password</div>
<div class="card" style="padding:16px 16px 20px">
<div class="form-group">
<label>Current Password</label>
<input type="password" id="pwCurrent" placeholder="••••••••">
</div>
<div class="form-group">
<label>New Password</label>
<input type="password" id="pwNew" placeholder="Minimum 6 characters">
</div>
<div class="form-group">
<label>Confirm New Password</label>
<input type="password" id="pwConfirm" placeholder="Repeat new password">
</div>
<button class="btn btn-primary" onclick="changePassword()">Update Password</button>
<span id="pwMsg" style="margin-left:10px;font-size:.82rem"></span>
</div>
<!-- Support / Donate ─────────────────────── -->
<?php
$donMsg = getSetting('donation_message', '');
$hasPayPal = getSetting('paypal_link', '') !== '';
$hasKofi = getSetting('kofi_link', '') !== '';
$hasBmc = getSetting('buymeacoffee_link', '') !== '';
if ($hasPayPal || $hasKofi || $hasBmc):
?>
<div class="section-label">Support the App</div>
<div class="card" style="padding:16px;text-align:center">
<?php if ($donMsg): ?>
<p style="color:var(--text-dim);font-size:.85rem;margin-bottom:14px">
<?= htmlspecialchars($donMsg) ?>
</p>
<?php endif; ?>
<a href="donate.php" class="btn btn-primary" style="text-decoration:none">❤️ Donate</a>
</div>
<?php endif; ?>
<!-- Admin Panel link (admins only) ──────── -->
<?php if (isAdmin()): ?>
<div class="section-label">Administration</div>
<div class="card">
<a href="admin.php" style="text-decoration:none">
<div class="card-row">
<div style="font-size:1.3rem;margin-right:12px">🛡️</div>
<div style="flex:1">
<div class="row-label">Admin Panel</div>
<div class="row-sub">Manage users, settings & donation links</div>
</div>
<div style="color:var(--text-muted)"></div>
</div>
</a>
</div>
<?php endif; ?>
<!-- Logout ──────────────────────────────── -->
<div class="section-label">Session</div>
<div class="card">
<div class="card-row">
<div style="flex:1">
<div class="row-label">Sign Out</div>
<div class="row-sub">Logged in as @<?= htmlspecialchars($user['username']) ?></div>
</div>
<a href="login.php?action=logout" class="btn btn-danger" style="text-decoration:none">Logout</a>
</div>
</div>
</div>
<?php renderNav('settings'); ?>
<script>
async function api(action, method='GET', body=null) {
const url = `api.php?action=${action}`;
const opt = { method };
if (body) { opt.headers={'Content-Type':'application/x-www-form-urlencoded'}; opt.body=body; }
const r = await fetch(url, opt);
return r.json();
}
async function saveProfile() {
const dn = document.getElementById('displayName').value.trim();
const res = await api('update_profile', 'POST', new URLSearchParams({display_name: dn}).toString());
const el = document.getElementById('profileMsg');
el.style.color = res.ok ? 'var(--green)' : 'var(--red)';
el.textContent = res.ok ? '✓ Saved' : '✗ Failed';
if (res.ok) setTimeout(() => el.textContent = '', 3000);
}
async function changePassword() {
const current = document.getElementById('pwCurrent').value;
const nw = document.getElementById('pwNew').value;
const confirm = document.getElementById('pwConfirm').value;
const el = document.getElementById('pwMsg');
if (nw.length < 6) {
el.style.color = 'var(--red)'; el.textContent = 'Min. 6 characters'; return;
}
if (nw !== confirm) {
el.style.color = 'var(--red)'; el.textContent = 'Passwords do not match'; return;
}
const res = await api('change_password', 'POST', new URLSearchParams({current, new: nw}).toString());
el.style.color = res.ok ? 'var(--green)' : 'var(--red)';
el.textContent = res.ok ? '✓ Password updated' : ('✗ ' + (res.error || 'Failed'));
if (res.ok) {
['pwCurrent','pwNew','pwConfirm'].forEach(id => document.getElementById(id).value = '');
setTimeout(() => el.textContent = '', 4000);
}
}
</script>
</body>
</html>

View file

@ -1,69 +0,0 @@
/*
style_additions.css
APPEND these rules to the bottom of style.css
*/
/* ── Topbar avatar (initials circle) ───────── */
.topbar-avatar {
position: absolute; left: 14px;
width: 32px; height: 32px;
border-radius: 50%;
background: rgba(255,255,255,.22);
color: #fff;
font-size: .8rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
text-decoration: none;
transition: background .15s;
user-select: none;
}
.topbar-avatar:hover { background: rgba(255,255,255,.35); }
/* ── User / admin badges ───────────────────── */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 20px;
font-size: .65rem; font-weight: 700;
vertical-align: middle;
margin-left: 4px;
}
.badge-admin {
background: rgba(58,180,242,.18);
color: var(--accent);
border: 1px solid rgba(58,180,242,.3);
}
.badge-suspended {
background: rgba(248,113,113,.18);
color: var(--red);
border: 1px solid rgba(248,113,113,.3);
}
/* ── Admin user action buttons ─────────────── */
.user-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.user-actions .btn { padding: 5px 10px; font-size: .75rem; }
/* ── Checkbox style in settings/admin ──────── */
input[type="checkbox"] {
width: 18px; height: 18px;
accent-color: var(--accent);
cursor: pointer;
}
/* ── Auth pages (login / register) ─────────── */
.auth-wrap {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 24px;
}
.auth-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 36px 30px;
max-width: 380px; width: 100%;
}
.auth-logo { font-size: 2.5rem; text-align: center; margin-bottom: 10px; }
.auth-card h1 { font-size: 1.2rem; text-align: center; margin-bottom: 6px; }
.auth-sub { color: var(--text-dim); font-size: .8rem; text-align: center; margin-bottom: 24px; }
.auth-footer { color: var(--text-muted); font-size: .8rem; text-align: center; margin-top: 16px; }
.auth-footer a { color: var(--accent); text-decoration: none; }
.auth-hint { color: var(--text-muted); font-size: .75rem; text-align: center; margin-top: 10px; }
.auth-hint code { background: var(--surface2); padding: 1px 5px; border-radius: 4px; color: var(--accent); }
.field-hint { font-size: .7rem; color: var(--text-muted); margin-top: 3px; }