Initial commit — Finance Planner setup
This commit is contained in:
commit
57baefe1c7
14 changed files with 1681 additions and 0 deletions
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
db.php
|
||||||
|
*.log
|
||||||
|
*.cache
|
||||||
|
/vendor/
|
||||||
|
*.env
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
251
admin.php
Normal file
251
admin.php
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
<?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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
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"> (you)</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="row-sub">
|
||||||
|
@${escHtml(u.username)} · Joined ${fmtDate(u.created_at)}
|
||||||
|
· ${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
Normal file
453
api.php
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
<?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
Normal file
113
auth.php
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
<?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]);
|
||||||
|
}
|
||||||
95
donate.php
Normal file
95
donate.php
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?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>
|
||||||
48
git-cron-setup.sh
Executable file
48
git-cron-setup.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/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 ""
|
||||||
98
git-setup.sh
Executable file
98
git-setup.sh
Executable file
|
|
@ -0,0 +1,98 @@
|
||||||
|
#!/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 ""
|
||||||
50
git-sync.sh
Executable file
50
git-sync.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/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 ""
|
||||||
20
gitignore
Normal file
20
gitignore
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# ── Sensitive config ──────────────────────────
|
||||||
|
db.php
|
||||||
|
|
||||||
|
# ── PHP / server ──────────────────────────────
|
||||||
|
*.log
|
||||||
|
*.cache
|
||||||
|
/vendor/
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# ── OS junk ───────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ── Editor files ──────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
95
login.php
Normal file
95
login.php
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?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
Normal file
85
nav.php
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?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'>+</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>';
|
||||||
|
}
|
||||||
136
register.php
Normal file
136
register.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?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 3–30 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">3–30 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>
|
||||||
155
settings.php
Normal file
155
settings.php
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<?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>
|
||||||
69
style_additions.css
Normal file
69
style_additions.css
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
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; }
|
||||||
Loading…
Add table
Reference in a new issue