- Implemented the complete first-run experience:

Account creation and guarded onboarding flow.
Current-cash starting balance with automatic rollover enabled.
Dynamic bill generation, shared/custom categories, recurring targets, 
and optional same-day onboarding transactions.
Optional dormant reserve and additional categories.
Mailgun email or authenticator-app 2FA configuration.
Responsive dark/light UI with no overflow at 390px.
Fixed administrator session-ID creation bug.
Core implementation: [OnboardingService.php (line 
59)](/Users/tyemeclifford/Documents/GH/budget/app/OnboardingService.php:59), 
[onboarding.php (line 
19)](/Users/tyemeclifford/Documents/GH/budget/views/onboarding.php:19), 
and [index.php (line 
143)](/Users/tyemeclifford/Documents/GH/budget/public/index.php:143).
Verification passed: financial self-test, onboarding integration test, 
full browser walkthrough, mobile layout, routing guards, and zero 
browser console errors. Live email delivery requires actual Mailgun 
credentials and was not sent during testing.
This commit is contained in:
Ty Clifford
2026-06-15 14:51:44 -04:00
parent 51ee4ecb5a
commit 6686388834
15 changed files with 1159 additions and 32 deletions
+9 -3
View File
@@ -1,12 +1,16 @@
<?php $appName = (string) $settings->get('app.name', 'Neon Ledger'); ?>
<?php
$appName = (string) $settings->get('app.name', 'Neon Ledger');
$defaultTheme = (string) $settings->get('theme.default', 'dark');
?>
<!doctype html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="<?= h($defaultTheme) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark light">
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
<link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
<script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script>
</head>
<body class="auth-body">
<main class="auth-shell">
@@ -23,11 +27,13 @@
</div>
</section>
<section class="auth-panel">
<button class="icon-control auth-theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><?= icon('sun') ?></button>
<?php foreach ($flashes as $flash): ?>
<div class="flash <?= h($flash['type']) ?>"><span>!</span><?= h($flash['message']) ?></div>
<?php endforeach; ?>
<?= $content ?>
</section>
</main>
<script src="/assets/app.js?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.js') ?>"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<?php
$appName = (string) $settings->get('app.name', 'Neon Ledger');
$defaultTheme = (string) $settings->get('theme.default', 'dark');
?>
<!doctype html>
<html lang="en" data-theme="<?= h($defaultTheme) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#090b12">
<title><?= h($title ?? 'Welcome') ?> · <?= h($appName) ?></title>
<link rel="stylesheet" href="/assets/app.css?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.css') ?>">
<script>document.documentElement.dataset.theme=localStorage.getItem('budget-theme')||<?= json_encode($defaultTheme) ?>;</script>
</head>
<body class="onboarding-body">
<div class="onboarding-shell">
<header class="onboarding-header">
<div class="brand onboarding-brand">
<span class="brand-mark"><i></i><b>N</b></span>
<span class="brand-copy"><strong><?= h($appName) ?></strong><small>Personal finance planner</small></span>
</div>
<div class="onboarding-account">
<span><small>Signed in as</small><strong><?= h($user['email'] ?? '') ?></strong></span>
<button class="icon-control" type="button" data-theme-toggle aria-label="Toggle light and dark mode"><?= icon('sun') ?></button>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="logout">
<button class="icon-control" type="submit" aria-label="Sign out"><?= icon('logout') ?></button>
</form>
</div>
</header>
<?php foreach ($flashes as $flash): ?>
<div class="flash onboarding-flash <?= h($flash['type']) ?>" role="status">
<span><?= $flash['type'] === 'success' ? '✓' : '!' ?></span>
<p><?= h($flash['message']) ?></p>
<button type="button" aria-label="Dismiss"><?= icon('close') ?></button>
</div>
<?php endforeach; ?>
<main class="onboarding-main"><?= $content ?></main>
<footer class="onboarding-footer">
<span>Private by default</span>
<span>SQLite powered · Your data stays under your control</span>
</footer>
</div>
<script src="/assets/app.js?v=<?= (int) filemtime(APP_ROOT . '/public/assets/app.js') ?>"></script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
<?php
$monthLabel = date('F Y', strtotime($month . '-01'));
$lastDay = date('Y-m-t', strtotime($month . '-01'));
$categoryOptions = [
'Utilities',
'Housing',
'Communications',
'Subscriptions',
'Insurance',
'Transportation',
'Debt',
'Food',
'Health',
'Childcare',
'Education',
];
$extraCategories = ['TV', 'Cellular', 'Internet', 'Disney+', 'Netflix', 'Luxury', 'Entertainment', 'Travel'];
?>
<section class="onboarding-intro">
<p class="eyebrow">A clean starting point</p>
<h1>Lets plan from what you have now.</h1>
<p>The day of the month does not matter. We will use todays available money as the starting point for <?= h($monthLabel) ?>, then carry any unused remainder forward.</p>
</section>
<div class="onboarding-progress" aria-label="Onboarding progress">
<?php foreach (['Cash now', 'Bill count', 'Build bills', 'Preferences'] as $index => $label): ?>
<div class="<?= $index === 0 ? 'active' : '' ?>" data-onboarding-progress="<?= $index + 1 ?>">
<span><?= $index + 1 ?></span><strong><?= h($label) ?></strong>
</div>
<?php endforeach; ?>
</div>
<form method="post" class="onboarding-form card" data-onboarding-form data-symbol="<?= h($symbol) ?>">
<?= csrf_field() ?>
<input type="hidden" name="action" value="onboarding.complete">
<section class="onboarding-step active" data-onboarding-step="1">
<div class="step-copy">
<span class="step-number">01</span>
<div>
<p class="eyebrow">Start with the truth</p>
<h2>How much money do you have in total right now?</h2>
<p>Include the cash you consider usable across checking, cash, and other active accounts. This is your manual starting point, not a forecast.</p>
</div>
</div>
<label class="hero-money-field">
<span>Current available cash</span>
<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="current_cash" min="0" step="0.01" inputmode="decimal" required autofocus placeholder="0.00" data-current-cash></div>
<small>Anything left at month-end can roll into the next month automatically.</small>
</label>
<div class="onboarding-actions">
<span></span>
<button class="button primary" type="button" data-onboarding-next>Continue to bills <?= icon('plus') ?></button>
</div>
</section>
<section class="onboarding-step" data-onboarding-step="2" hidden>
<div class="step-copy">
<span class="step-number">02</span>
<div>
<p class="eyebrow">Map the obligations</p>
<h2>How many bills do you have in total?</h2>
<p>We will generate one simple setup card for each bill. Enter zero if you would rather add bills from the dashboard later.</p>
</div>
</div>
<label class="bill-count-field">
<span>Number of bills</span>
<input type="number" name="bill_count" min="0" max="50" step="1" inputmode="numeric" required value="0" data-bill-count>
<small>Up to 50 can be created during onboarding.</small>
</label>
<div class="onboarding-actions">
<button class="button secondary" type="button" data-onboarding-back>Back</button>
<button class="button primary" type="button" data-onboarding-next>Generate bill fields</button>
</div>
</section>
<section class="onboarding-step" data-onboarding-step="3" hidden>
<div class="step-copy">
<span class="step-number">03</span>
<div>
<p class="eyebrow">Turn bills into targets</p>
<h2>Build the first months bill plan.</h2>
<p>Keep a category specific to the bill, or consolidate related bills such as electric, water, sewage, and gas under Utilities.</p>
</div>
</div>
<label class="check-row onboarding-check-all">
<input type="checkbox" data-mark-all-paid>
<span><strong>Mark every entered bill as paid today</strong><small>Each payment will reduce the available balance and create a transaction dated <?= h(date('F j, Y', strtotime($today))) ?> with the note “onboarding.”</small></span>
</label>
<div class="onboarding-bills" data-bill-rows></div>
<div class="onboarding-actions">
<button class="button secondary" type="button" data-onboarding-back>Back</button>
<button class="button primary" type="button" data-onboarding-next>Continue to preferences</button>
</div>
</section>
<section class="onboarding-step" data-onboarding-step="4" hidden>
<div class="step-copy">
<span class="step-number">04</span>
<div>
<p class="eyebrow">Set aside and finish</p>
<h2>Add useful categories and decide what stays out of sight.</h2>
<p>These choices are optional. Everything can be changed later from the administrator dashboard.</p>
</div>
</div>
<div class="onboarding-preference-grid">
<div class="onboarding-option-panel">
<div class="option-heading">
<span class="metric-icon blue"><?= icon('expense') ?></span>
<div><h3>Additional categories</h3><p>Create common categories now, even if they are not connected to a bill yet.</p></div>
</div>
<div class="category-chip-grid">
<?php foreach ($extraCategories as $category): ?>
<label><input type="checkbox" name="extra_categories[]" value="<?= h($category) ?>"><span><?= h($category) ?></span></label>
<?php endforeach; ?>
</div>
</div>
<div class="onboarding-option-panel dead-option">
<div class="option-heading">
<span class="metric-icon purple"><?= icon('vault') ?></span>
<div><h3>Dormant account</h3><p>Move money out of the usable budget without losing it. The hidden balance can be restored to income or a bill later.</p></div>
</div>
<label class="check-row">
<input type="checkbox" name="enable_dead_account" value="1" data-enable-onboarding-dead>
<span><strong>Set money aside now</strong><small>Unchecked by default. This reduces the money available for the month.</small></span>
</label>
<label data-onboarding-dead-fields hidden>
Amount to make dormant
<div class="money-input"><span><?= h($symbol) ?></span><input type="number" name="dead_amount" min="0" step="0.01" inputmode="decimal" placeholder="0.00" data-dead-amount></div>
</label>
</div>
</div>
<div class="onboarding-review">
<div><span>Starting cash</span><strong data-review-cash><?= h($symbol) ?>0.00</strong></div>
<div><span>Paid during setup</span><strong data-review-paid><?= h($symbol) ?>0.00</strong></div>
<div><span>Made dormant</span><strong data-review-dead><?= h($symbol) ?>0.00</strong></div>
<div class="review-remaining"><span>Ready to budget</span><strong data-review-remaining><?= h($symbol) ?>0.00</strong></div>
</div>
<p class="onboarding-warning" data-onboarding-warning hidden>Payments and dormant money exceed the cash you entered. Adjust an amount before finishing.</p>
<div class="onboarding-actions">
<button class="button secondary" type="button" data-onboarding-back>Back</button>
<button class="button primary" type="submit" data-onboarding-submit>Finish and open dashboard</button>
</div>
</section>
</form>
<template id="onboarding-bill-template">
<article class="onboarding-bill-card">
<div class="bill-card-heading">
<span class="bill-index" data-bill-index>1</span>
<div><h3 data-bill-title>Bill 1</h3><p>Creates a progress-tracked budget target.</p></div>
</div>
<div class="bill-fields">
<label>Bill name<input type="text" maxlength="255" required placeholder="Electric" data-bill-field="name"></label>
<label>Usual amount<div class="money-input"><span><?= h($symbol) ?></span><input type="number" min="0" step="0.01" inputmode="decimal" required placeholder="0.00" data-bill-field="amount"></div></label>
<label>Due date<input type="date" min="<?= h($month . '-01') ?>" max="<?= h($lastDay) ?>" value="<?= h($today) ?>" data-bill-field="due_date"></label>
<label>Repeats
<select data-bill-field="recurrence">
<option value="monthly" selected>Every month</option>
<option value="yearly">Every year</option>
<option value="none">One time</option>
</select>
</label>
<label class="bill-category-field">Category
<select data-bill-field="category">
<option value="__bill__">Use the bill name</option>
<?php foreach ($categoryOptions as $category): ?>
<option value="<?= h($category) ?>"><?= h($category) ?></option>
<?php endforeach; ?>
<option value="__custom__">Custom category…</option>
</select>
</label>
<label class="bill-custom-category" data-custom-category hidden>Custom category<input type="text" maxlength="191" placeholder="My category" data-bill-field="custom_category"></label>
</div>
<label class="check-row bill-paid-row">
<input type="checkbox" value="1" data-bill-field="paid_now">
<span><strong>This bill was paid today</strong><small>Deduct the amount now and add an “onboarding” transaction to history.</small></span>
</label>
</article>
</template>
+15 -5
View File
@@ -73,7 +73,15 @@
<article class="card" id="security">
<div class="card-heading"><div><p class="eyebrow">Administrator protection</p><h2>Security</h2></div></div>
<label class="check-row"><input type="checkbox" name="security_2fa_required" value="1" <?= $s['security.2fa_required'] ? 'checked' : '' ?>><span><strong>Require two-factor authentication</strong><small>Uses any standard TOTP authenticator app.</small></span></label>
<div class="form-grid">
<label>Two-factor method
<select name="security_2fa_method">
<option value="totp" <?= $s['security.2fa_method'] === 'totp' ? 'selected' : '' ?>>Authenticator app</option>
<option value="email" <?= $s['security.2fa_method'] === 'email' ? 'selected' : '' ?>>Email through Mailgun</option>
</select>
</label>
<label class="check-row"><input type="checkbox" name="security_2fa_required" value="1" <?= $s['security.2fa_required'] ? 'checked' : '' ?>><span><strong>Require two-factor authentication</strong><small>Email delivery requires complete Mailgun settings above.</small></span></label>
</div>
<p class="form-note">Administrator: <?= h($user['email']) ?> · account created <?= h(date('M j, Y', strtotime($user['created_at']))) ?></p>
</article>
@@ -83,10 +91,12 @@
</form>
<section class="settings-actions">
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="2fa.regenerate">
<button class="button secondary" type="submit">Generate new 2FA key</button>
</form>
<?php if (($s['security.2fa_method'] ?? 'totp') === 'totp'): ?>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="action" value="2fa.regenerate">
<button class="button secondary" type="submit">Generate new 2FA key</button>
</form>
<?php endif; ?>
<?php if ($settings->module('alerts')): ?>
<form method="post" data-confirm="Run the reminder scan now? Configured emails may be sent through Mailgun.">
<?= csrf_field() ?><input type="hidden" name="action" value="alerts.run">
+8 -5
View File
@@ -1,14 +1,17 @@
<div class="auth-card">
<p class="eyebrow">First run</p>
<h2>Create the administrator</h2>
<p class="muted">This account controls the planner, its modules, exports, and alert settings.</p>
<p class="eyebrow">Welcome to Neon Ledger</p>
<h2>Create your private planner</h2>
<p class="muted">Plan from the money you actually have, build monthly bill targets, and carry unused funds safely into the next month.</p>
<form method="post" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="setup">
<label>Email address<input type="email" name="email" required autocomplete="email" placeholder="you@example.com"></label>
<label>Password<input type="password" name="password" required minlength="10" autocomplete="new-password" placeholder="At least 10 characters"></label>
<label>Confirm password<input type="password" name="password_confirmation" required minlength="10" autocomplete="new-password"></label>
<label class="check-row"><input type="checkbox" name="enable_2fa" value="1"><span><strong>Enable optional 2FA now</strong><small>You will receive a setup key after account creation.</small></span></label>
<button class="button primary full" type="submit">Create planner</button>
<div class="auth-info">
<strong>Protection and reminders come next</strong>
<small>Optional authenticator or email-delivered 2FA and Mailgun bill alerts are available in Configuration after onboarding.</small>
</div>
<button class="button primary full" type="submit">Create account and continue</button>
</form>
</div>
+3 -1
View File
@@ -1,7 +1,9 @@
<div class="auth-card">
<p class="eyebrow">Second step</p>
<h2>Authentication code</h2>
<p class="muted">Enter the current six-digit code from your authenticator.</p>
<p class="muted"><?= $twoFactorMethod === 'email'
? 'Enter the six-digit code sent to the administrator email. It expires in 10 minutes.'
: 'Enter the current six-digit code from your authenticator.' ?></p>
<form method="post" class="stack-form">
<?= csrf_field() ?>
<input type="hidden" name="action" value="verify_2fa">