- Implemented the full Fat Bottom Grille storefront and staff dashboard.
Highlights include configurable menus, specials, cart/checkout, taxes, customer accounts with email 2FA, newsletters, analytics, refunds, PDF/CSV exports, Square/Mailgun adapters, and optional MySQL migration. See README.md for setup and production configuration. Verified PHP/JavaScript syntax, responsive layouts, registration, 2FA, checkout, dashboard workflows, settings persistence, and PDF generation. Real Square, Mailgun, and MySQL connections require credentials/server access. Square implementation follows the official Payments API and Refunds API.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FatBottom\Controllers;
|
||||
|
||||
use FatBottom\Core\Auth;
|
||||
use FatBottom\Core\Config;
|
||||
use FatBottom\Core\Database;
|
||||
use FatBottom\Core\Http;
|
||||
use FatBottom\Core\Security;
|
||||
use FatBottom\Core\View;
|
||||
use FatBottom\Services\CartService;
|
||||
use FatBottom\Services\TwoFactorService;
|
||||
|
||||
final class AuthController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
Config $config,
|
||||
Auth $auth,
|
||||
CartService $cart,
|
||||
private readonly Database $db,
|
||||
private readonly TwoFactorService $twoFactor
|
||||
) {
|
||||
parent::__construct($config, $auth, $cart);
|
||||
}
|
||||
|
||||
public function loginForm(): void
|
||||
{
|
||||
if ($this->auth->check()) {
|
||||
Http::redirect('/account');
|
||||
}
|
||||
View::render('auth/login', $this->shared([
|
||||
'title' => 'Sign In',
|
||||
'captcha' => Security::newCaptcha(),
|
||||
]));
|
||||
}
|
||||
|
||||
public function login(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$input = $_POST;
|
||||
$email = strtolower(Security::clean((string) ($input['email'] ?? '')));
|
||||
if (!Security::verifyCaptcha($input)) {
|
||||
Http::old(['email' => $email]);
|
||||
Http::flash('error', 'Please complete the anti-spam check.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
|
||||
$user = $this->db->fetch('SELECT * FROM users WHERE email = ? AND active = 1', [$email]);
|
||||
if ($user === null || !password_verify((string) ($input['password'] ?? ''), (string) $user['password_hash'])) {
|
||||
usleep(350000);
|
||||
Http::old(['email' => $email]);
|
||||
Http::flash('error', 'The email address or password was not recognized.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
|
||||
unset($_SESSION['pending_verification_purpose']);
|
||||
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
|
||||
$developmentCode = $this->twoFactor->issue($user, 'login');
|
||||
if ($developmentCode !== null) {
|
||||
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
|
||||
} else {
|
||||
Http::flash('success', 'We emailed you a six-digit verification code.');
|
||||
}
|
||||
Http::redirect('/verify');
|
||||
}
|
||||
|
||||
public function registerForm(): void
|
||||
{
|
||||
if ($this->auth->check()) {
|
||||
Http::redirect('/account');
|
||||
}
|
||||
View::render('auth/register', $this->shared([
|
||||
'title' => 'Create Account',
|
||||
'captcha' => Security::newCaptcha(),
|
||||
]));
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$input = $_POST;
|
||||
$clean = [
|
||||
'email' => strtolower(Security::clean((string) ($input['email'] ?? ''))),
|
||||
'full_name' => Security::clean((string) ($input['full_name'] ?? '')),
|
||||
'phone' => Security::clean((string) ($input['phone'] ?? '')),
|
||||
'street_address' => Security::clean((string) ($input['street_address'] ?? '')),
|
||||
'city' => Security::clean((string) ($input['city'] ?? '')),
|
||||
'state' => strtoupper(substr(Security::clean((string) ($input['state'] ?? 'WV')), 0, 2)),
|
||||
'postal_code' => Security::clean((string) ($input['postal_code'] ?? '')),
|
||||
];
|
||||
|
||||
if (!Security::verifyCaptcha($input)) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'Please complete the anti-spam check.');
|
||||
Http::redirect('/register');
|
||||
}
|
||||
if (
|
||||
!filter_var($clean['email'], FILTER_VALIDATE_EMAIL)
|
||||
|| $clean['full_name'] === ''
|
||||
|| $clean['phone'] === ''
|
||||
|| $clean['street_address'] === ''
|
||||
|| $clean['city'] === ''
|
||||
|| $clean['state'] === ''
|
||||
|| $clean['postal_code'] === ''
|
||||
|| strlen((string) ($input['password'] ?? '')) < 10
|
||||
) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'Complete your contact and address details, enter a valid email, and use a password of at least 10 characters.');
|
||||
Http::redirect('/register');
|
||||
}
|
||||
if (($input['password'] ?? '') !== ($input['password_confirmation'] ?? '')) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'The password confirmation does not match.');
|
||||
Http::redirect('/register');
|
||||
}
|
||||
if ($this->db->fetch('SELECT id FROM users WHERE email = ?', [$clean['email']])) {
|
||||
Http::old($clean);
|
||||
Http::flash('error', 'An account already exists for that email address.');
|
||||
Http::redirect('/login');
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO users
|
||||
(email, password_hash, full_name, phone, street_address, city, state, postal_code, newsletter_opt_in)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)',
|
||||
[
|
||||
$clean['email'],
|
||||
password_hash((string) $input['password'], PASSWORD_DEFAULT),
|
||||
$clean['full_name'],
|
||||
$clean['phone'],
|
||||
$clean['street_address'],
|
||||
$clean['city'],
|
||||
$clean['state'],
|
||||
$clean['postal_code'],
|
||||
]
|
||||
);
|
||||
$user = $this->db->fetch('SELECT * FROM users WHERE email = ?', [$clean['email']]);
|
||||
$_SESSION['pending_2fa_user_id'] = (int) $user['id'];
|
||||
$_SESSION['pending_verification_purpose'] = 'register';
|
||||
$developmentCode = $this->twoFactor->issue($user, 'register');
|
||||
if ($developmentCode !== null) {
|
||||
Http::flash('info', 'Development mail mode: your verification code is ' . $developmentCode . '.');
|
||||
}
|
||||
Http::redirect('/verify');
|
||||
}
|
||||
|
||||
public function verifyForm(): void
|
||||
{
|
||||
if (empty($_SESSION['pending_2fa_user_id'])) {
|
||||
Http::redirect('/login');
|
||||
}
|
||||
View::render('auth/verify', $this->shared([
|
||||
'title' => 'Verify Your Email',
|
||||
]));
|
||||
}
|
||||
|
||||
public function verify(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$userId = (int) ($_SESSION['pending_2fa_user_id'] ?? 0);
|
||||
$purpose = (string) ($_SESSION['pending_verification_purpose'] ?? 'login');
|
||||
if (!$this->twoFactor->verify($userId, Security::clean((string) ($_POST['code'] ?? '')), $purpose)) {
|
||||
Http::flash('error', 'That code is invalid or has expired.');
|
||||
Http::redirect('/verify');
|
||||
}
|
||||
|
||||
$user = $this->db->fetch('SELECT * FROM users WHERE id = ?', [$userId]);
|
||||
if ($user === null) {
|
||||
Http::redirect('/login');
|
||||
}
|
||||
if ($purpose === 'register') {
|
||||
$this->db->execute('UPDATE users SET email_verified_at = CURRENT_TIMESTAMP WHERE id = ?', [$userId]);
|
||||
unset($_SESSION['pending_verification_purpose']);
|
||||
}
|
||||
$this->auth->login($user);
|
||||
Http::flash('success', 'Welcome, ' . $user['full_name'] . '.');
|
||||
Http::redirect(in_array($user['role'], ['admin', 'manager', 'staff'], true) ? '/admin' : '/account');
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$this->auth->logout();
|
||||
Http::flash('success', 'You have been signed out.');
|
||||
Http::redirect('/');
|
||||
}
|
||||
|
||||
public function account(): void
|
||||
{
|
||||
$user = $this->auth->requireLogin();
|
||||
$orders = $this->db->fetchAll(
|
||||
'SELECT * FROM orders WHERE user_id = ? ORDER BY placed_at DESC LIMIT 25',
|
||||
[(int) $user['id']]
|
||||
);
|
||||
View::render('account/index', $this->shared([
|
||||
'title' => 'My Account',
|
||||
'orders' => $orders,
|
||||
]));
|
||||
}
|
||||
|
||||
public function updateAccount(): void
|
||||
{
|
||||
Security::verifyCsrf();
|
||||
$user = $this->auth->requireLogin();
|
||||
$newsletter = isset($_POST['newsletter_opt_in']) ? 1 : 0;
|
||||
$newPassword = (string) ($_POST['new_password'] ?? '');
|
||||
if (
|
||||
$newPassword !== ''
|
||||
&& (
|
||||
!password_verify((string) ($_POST['current_password'] ?? ''), (string) $user['password_hash'])
|
||||
|| strlen($newPassword) < 10
|
||||
|| $newPassword !== (string) ($_POST['new_password_confirmation'] ?? '')
|
||||
)
|
||||
) {
|
||||
Http::flash('error', 'Password was not changed. Check your current password and use a matching new password of at least 10 characters.');
|
||||
Http::redirect('/account');
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'UPDATE users SET full_name = ?, phone = ?, street_address = ?, city = ?, state = ?,
|
||||
postal_code = ?, newsletter_opt_in = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[
|
||||
Security::clean((string) ($_POST['full_name'] ?? '')),
|
||||
Security::clean((string) ($_POST['phone'] ?? '')),
|
||||
Security::clean((string) ($_POST['street_address'] ?? '')),
|
||||
Security::clean((string) ($_POST['city'] ?? '')),
|
||||
strtoupper(substr(Security::clean((string) ($_POST['state'] ?? 'WV')), 0, 2)),
|
||||
Security::clean((string) ($_POST['postal_code'] ?? '')),
|
||||
$newsletter,
|
||||
(int) $user['id'],
|
||||
]
|
||||
);
|
||||
|
||||
if ($newPassword !== '') {
|
||||
$this->db->execute(
|
||||
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[password_hash($newPassword, PASSWORD_DEFAULT), (int) $user['id']]
|
||||
);
|
||||
}
|
||||
|
||||
Http::flash('success', 'Your account has been updated.');
|
||||
Http::redirect('/account');
|
||||
}
|
||||
|
||||
public function unsubscribe(): void
|
||||
{
|
||||
$userId = (int) ($_GET['user'] ?? 0);
|
||||
$signature = (string) ($_GET['signature'] ?? '');
|
||||
$expected = hash_hmac('sha256', (string) $userId, $this->newsletterKey());
|
||||
if ($userId > 0 && hash_equals($expected, $signature)) {
|
||||
$this->db->execute('UPDATE users SET newsletter_opt_in = 0 WHERE id = ?', [$userId]);
|
||||
Http::flash('success', 'You have been unsubscribed from email news.');
|
||||
} else {
|
||||
Http::flash('error', 'That unsubscribe link is invalid.');
|
||||
}
|
||||
Http::redirect('/');
|
||||
}
|
||||
|
||||
private function newsletterKey(): string
|
||||
{
|
||||
return hash('sha256', (string) $this->config->get('app.key') . '|newsletter');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user