- 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
+88 -11
View File
@@ -61,7 +61,7 @@ function redirect_to(string $route, array $parameters = []): never
exit;
}
function render_view(string $view, array $data = [], bool $authLayout = false): never
function render_view(string $view, array $data = [], string|bool $layout = false): never
{
global $settings, $auth;
extract($data, EXTR_SKIP);
@@ -69,10 +69,20 @@ function render_view(string $view, array $data = [], bool $authLayout = false):
ob_start();
require APP_ROOT . '/views/' . $view . '.php';
$content = (string) ob_get_clean();
require APP_ROOT . '/views/' . ($authLayout ? 'auth-layout.php' : 'layout.php');
$layoutView = $layout === true ? 'auth-layout' : (is_string($layout) && $layout !== '' ? $layout : 'layout');
require APP_ROOT . '/views/' . $layoutView . '.php';
exit;
}
function onboarding_is_complete(): bool
{
global $settings, $onboarding;
if (!$settings->has('onboarding.completed')) {
$settings->set('onboarding.completed', $onboarding->hasFinancialData());
}
return (bool) $settings->get('onboarding.completed', false);
}
$route = (string) ($_GET['route'] ?? ($auth->check() ? 'dashboard' : 'login'));
$month = Support::month((string) ($_GET['month'] ?? $_POST['month'] ?? date('Y-m')));
@@ -93,12 +103,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (strlen($password) < 10 || $password !== (string) ($_POST['password_confirmation'] ?? '')) {
throw new RuntimeException('Use at least 10 characters and make sure both passwords match.');
}
$secret = $auth->setup((string) $email, $password, isset($_POST['enable_2fa']));
if ($secret) {
$_SESSION['new_totp_secret'] = $secret;
}
Support::flash('success', 'Administrator account created. Your planner is ready.');
redirect_to($secret ? 'settings' : 'dashboard');
$auth->setup((string) $email, $password, false);
Support::flash('success', 'Account created. Lets build your starting plan.');
redirect_to('welcome');
}
if ($action === 'login') {
@@ -133,6 +140,44 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
if ($action === 'onboarding.complete') {
$billCount = max(0, min(50, (int) ($_POST['bill_count'] ?? 0)));
$bills = isset($_POST['bills']) && is_array($_POST['bills'])
? array_values($_POST['bills'])
: [];
if (count($bills) !== $billCount) {
throw new RuntimeException('The bill list changed before it could be saved. Please generate the fields again.');
}
$availableExtraCategories = ['TV', 'Cellular', 'Internet', 'Disney+', 'Netflix', 'Luxury', 'Entertainment', 'Travel'];
$requestedExtraCategories = isset($_POST['extra_categories']) && is_array($_POST['extra_categories'])
? array_values(array_filter($_POST['extra_categories'], 'is_string'))
: [];
$extraCategories = array_values(array_intersect($availableExtraCategories, $requestedExtraCategories));
$result = $onboarding->complete(
date('Y-m'),
(float) ($_POST['current_cash'] ?? 0),
$bills,
$extraCategories,
isset($_POST['enable_dead_account']) ? (float) ($_POST['dead_amount'] ?? 0) : 0
);
Support::audit($pdo, $auth->id(), 'onboarding.completed', 'monthly_plan', null, $result);
Support::flash(
'success',
sprintf(
'Starting plan created with %d bill%s and %d categor%s.',
$result['bills'],
$result['bills'] === 1 ? '' : 's',
$result['categories'],
$result['categories'] === 1 ? 'y' : 'ies'
)
);
redirect_to('dashboard', ['month' => date('Y-m')]);
}
if (!onboarding_is_complete()) {
redirect_to('welcome');
}
if ($action === 'plan.save') {
$budget->savePlan(
$month,
@@ -363,13 +408,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$requireTwoFactor = isset($_POST['security_2fa_required']);
$twoFactorMethod = in_array((string) ($_POST['security_2fa_method'] ?? 'totp'), ['totp', 'email'], true)
? (string) $_POST['security_2fa_method']
: 'totp';
$user = $auth->user();
if ($requireTwoFactor && empty($user['totp_secret'])) {
if ($requireTwoFactor && $twoFactorMethod === 'email' && !(new Mailgun($settings))->configured()) {
throw new RuntimeException('Complete the Mailgun fields before requiring email two-factor authentication.');
}
if ($requireTwoFactor && $twoFactorMethod === 'totp' && empty($user['totp_secret'])) {
$secret = Totp::generateSecret();
$statement = $pdo->prepare('UPDATE users SET totp_secret = :secret, updated_at = CURRENT_TIMESTAMP WHERE id = :id');
$statement->execute(['secret' => $secret, 'id' => $auth->id()]);
$_SESSION['new_totp_secret'] = $secret;
}
$settings->set('security.2fa_method', $twoFactorMethod);
$settings->set('security.2fa_required', $requireTwoFactor);
Support::audit($pdo, $auth->id(), 'settings.updated');
Support::flash('success', 'Planner configuration saved.');
@@ -434,7 +486,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
throw new RuntimeException('Unknown action.');
} catch (Throwable $exception) {
Support::flash('error', $exception->getMessage());
$fallbackRoute = in_array($route, ['login', 'setup', 'two-factor'], true) ? $route : ($route ?: 'dashboard');
$fallbackRoute = in_array($route, ['login', 'setup', 'two-factor', 'welcome'], true) ? $route : ($route ?: 'dashboard');
if ($auth->check() && !onboarding_is_complete()) {
$fallbackRoute = 'welcome';
}
redirect_to($fallbackRoute, $auth->check() && $month ? ['month' => $month] : []);
}
}
@@ -449,13 +504,35 @@ if ($auth->needsSetup()) {
if (!$auth->check()) {
if ($route === 'two-factor' && isset($_SESSION['pending_2fa_user'])) {
render_view('two-factor', ['title' => 'Two-factor verification'], true);
render_view('two-factor', [
'title' => 'Two-factor verification',
'twoFactorMethod' => (string) ($_SESSION['pending_2fa_method'] ?? 'totp'),
], true);
}
render_view('login', ['title' => 'Administrator sign in'], true);
}
$onboardingComplete = onboarding_is_complete();
if (!$onboardingComplete && $route !== 'welcome') {
redirect_to('welcome');
}
if ($onboardingComplete && $route === 'welcome') {
redirect_to('dashboard', ['month' => date('Y-m')]);
}
Support::trackVisit($pdo, $route);
if ($route === 'welcome') {
render_view('onboarding', [
'title' => 'Build your starting plan',
'route' => 'welcome',
'month' => date('Y-m'),
'today' => date('Y-m-d'),
'symbol' => (string) $settings->get('app.currency_symbol', '$'),
'user' => $auth->user(),
], 'onboarding-layout');
}
if (in_array($route, ['export-json', 'export-html', 'export-pdf'], true)) {
$exporter = new ExportService($pdo, $settings, $budget);
if ($route === 'export-json') {