- 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:
+88
-11
@@ -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. Let’s 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') {
|
||||
|
||||
Reference in New Issue
Block a user