| @@ -4,7 +4,17 @@ | |||||
| "Bash(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force)", | "Bash(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force)", | ||||
| "Bash(Select-Object Mode, Name)", | "Bash(Select-Object Mode, Name)", | ||||
| "Bash(Format-Table -AutoSize)", | "Bash(Format-Table -AutoSize)", | ||||
| "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force | Where-Object {$_.Name -match '^[A-Z]'} | Select-Object Mode, Name)" | |||||
| "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force | Where-Object {$_.Name -match '^[A-Z]'} | Select-Object Mode, Name)", | |||||
| "Bash(Get-ChildItem -Recurse -Depth 3)", | |||||
| "Bash(Select-Object -Property FullName, PSIsContainer)", | |||||
| "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Recurse -Depth 3 | Select-Object -Property @{Name=\"Path\";Expression={$_.FullName.Substring\\(28\\)}}, PSIsContainer | Format-Table -AutoSize)", | |||||
| "PowerShell(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Depth 1 | Where-Object { $_.PSIsContainer } | ForEach-Object { $_.Name })", | |||||
| "Bash(composer install *)", | |||||
| "PowerShell(docker exec php-mvc-territory-app-1 cat /var/www/html/vendor/cartalyst/sentinel/src/Native/SentinelBootstrapper.php 2>&1)", | |||||
| "PowerShell(docker exec php-mvc-territory-app-1 cat /var/www/html/vendor/cartalyst/sentinel/src/Native/Facades/Sentinel.php 2>&1)", | |||||
| "PowerShell(docker exec php-mvc-territory-app-1 cat /var/www/html/vendor/cartalyst/sentinel/src/Native/ConfigRepository.php 2>&1)", | |||||
| "PowerShell(docker exec php-mvc-territory-app-1 cat /var/www/html/vendor/cartalyst/sentinel/src/config/config.php 2>&1)", | |||||
| "Bash(docker-compose exec app bash -c ' *)" | |||||
| ] | ] | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,71 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace App\Controllers; | |||||
| use Core\Controller; | |||||
| use Core\Request; | |||||
| use Core\Response; | |||||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||||
| use Cartalyst\Sentinel\Checkpoints\ThrottlingException; | |||||
| class AuthController extends Controller | |||||
| { | |||||
| public function showLogin(): Response | |||||
| { | |||||
| if (Sentinel::check()) { | |||||
| return $this->redirect('/'); | |||||
| } | |||||
| return $this->view('auth.login', ['pageTitle' => 'Login']); | |||||
| } | |||||
| public function login(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| if (!verify_csrf_token($request->input('_token'))) { | |||||
| return $this->view('auth.login', [ | |||||
| 'pageTitle' => 'Login', | |||||
| 'error' => 'Invalid request. Please try again.', | |||||
| ]); | |||||
| } | |||||
| $credentials = [ | |||||
| 'email' => (string) $request->input('email'), | |||||
| 'password' => (string) $request->input('password'), | |||||
| ]; | |||||
| $remember = (bool) $request->input('remember'); | |||||
| try { | |||||
| if (Sentinel::authenticate($credentials, $remember)) { | |||||
| return $this->redirect('/'); | |||||
| } | |||||
| } catch (ThrottlingException $e) { | |||||
| return $this->view('auth.login', [ | |||||
| 'pageTitle' => 'Login', | |||||
| 'error' => 'Too many failed attempts. Please wait ' . $e->getDelay() . ' seconds.', | |||||
| ]); | |||||
| } | |||||
| return $this->view('auth.login', [ | |||||
| 'pageTitle' => 'Login', | |||||
| 'error' => 'Invalid email or password.', | |||||
| ]); | |||||
| } | |||||
| public function logout(): Response | |||||
| { | |||||
| $request = Request::capture(); | |||||
| if (!verify_csrf_token($request->input('_token'))) { | |||||
| return $this->redirect('/'); | |||||
| } | |||||
| Sentinel::logout(); | |||||
| return $this->redirect('/login'); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,52 @@ | |||||
| <div class="auth-wrap"> | |||||
| <div class="auth-card"> | |||||
| <div class="auth-card-header"> | |||||
| <span class="eyebrow">Welcome back</span> | |||||
| <h1 class="auth-card-title">Sign in</h1> | |||||
| <p class="auth-card-subtitle">Enter your credentials to access your account.</p> | |||||
| </div> | |||||
| <?php if (!empty($error)): ?> | |||||
| <div class="alert alert-error" role="alert"> | |||||
| <?= e($error) ?> | |||||
| </div> | |||||
| <?php endif; ?> | |||||
| <form class="auth-form" method="POST" action="/login" novalidate> | |||||
| <?= csrf_field() ?> | |||||
| <div class="field"> | |||||
| <label for="email">Email address</label> | |||||
| <input | |||||
| class="input" | |||||
| type="email" | |||||
| id="email" | |||||
| name="email" | |||||
| value="<?= e($_POST['email'] ?? '') ?>" | |||||
| autocomplete="email" | |||||
| required | |||||
| autofocus | |||||
| > | |||||
| </div> | |||||
| <div class="field"> | |||||
| <label for="password">Password</label> | |||||
| <input | |||||
| class="input" | |||||
| type="password" | |||||
| id="password" | |||||
| name="password" | |||||
| autocomplete="current-password" | |||||
| required | |||||
| > | |||||
| </div> | |||||
| <div class="auth-remember"> | |||||
| <input type="checkbox" id="remember" name="remember" value="1"> | |||||
| <label for="remember">Keep me signed in</label> | |||||
| </div> | |||||
| <button class="button button-primary auth-submit" type="submit">Sign in</button> | |||||
| </form> | |||||
| </div> | |||||
| </div> | |||||
| @@ -2,6 +2,8 @@ | |||||
| declare(strict_types=1); | declare(strict_types=1); | ||||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||||
| $navigationItems = [ | $navigationItems = [ | ||||
| ['label' => 'Home', 'href' => '/'], | ['label' => 'Home', 'href' => '/'], | ||||
| ['label' => 'Example JSON', 'href' => '/users/123'], | ['label' => 'Example JSON', 'href' => '/users/123'], | ||||
| @@ -9,6 +11,8 @@ $navigationItems = [ | |||||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | ||||
| $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | ||||
| $currentUser = Sentinel::check(); | |||||
| ?> | ?> | ||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||
| <html lang="en"> | <html lang="en"> | ||||
| @@ -37,6 +41,15 @@ $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : ' | |||||
| <?= e($item['label']) ?> | <?= e($item['label']) ?> | ||||
| </a> | </a> | ||||
| <?php endforeach; ?> | <?php endforeach; ?> | ||||
| <?php if ($currentUser): ?> | |||||
| <form class="auth-nav-form" method="POST" action="/logout"> | |||||
| <?= csrf_field() ?> | |||||
| <button class="auth-nav-btn" type="submit">Sign out</button> | |||||
| </form> | |||||
| <?php else: ?> | |||||
| <a class="nav-link<?= $currentPath === '/login' ? ' is-active' : '' ?>" href="/login">Sign in</a> | |||||
| <?php endif; ?> | |||||
| </nav> | </nav> | ||||
| </div> | </div> | ||||
| </header> | </header> | ||||
| @@ -0,0 +1,21 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Illuminate\Database\Capsule\Manager as Capsule; | |||||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||||
| use Cartalyst\Sentinel\Native\SentinelBootstrapper; | |||||
| prepareSqliteDatabase('sqlite:' . __DIR__ . '/../database/app.sqlite'); | |||||
| $capsule = new Capsule(); | |||||
| $capsule->addConnection([ | |||||
| 'driver' => 'sqlite', | |||||
| 'database' => realpath(__DIR__ . '/../database/app.sqlite') ?: __DIR__ . '/../database/app.sqlite', | |||||
| 'prefix' => '', | |||||
| ]); | |||||
| $capsule->setAsGlobal(); | |||||
| $capsule->bootEloquent(); | |||||
| $bootstrapper = new SentinelBootstrapper(__DIR__ . '/../config/sentinel.php'); | |||||
| Sentinel::instance($bootstrapper); | |||||
| @@ -18,5 +18,11 @@ | |||||
| "migrate:fresh": "php scripts/migrate.php fresh", | "migrate:fresh": "php scripts/migrate.php fresh", | ||||
| "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" | "migrate:fresh-seed": "php scripts/migrate.php fresh --seed" | ||||
| }, | }, | ||||
| "require": {} | |||||
| "require": { | |||||
| "php": ">=8.2", | |||||
| "cartalyst/sentinel": "^7.0", | |||||
| "illuminate/database": "^10.0", | |||||
| "illuminate/events": "^10.0", | |||||
| "symfony/http-foundation": "^6.0" | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,66 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| return [ | |||||
| 'session' => 'cartalyst_sentinel', | |||||
| 'cookie' => 'cartalyst_sentinel', | |||||
| 'users' => [ | |||||
| 'model' => 'Cartalyst\Sentinel\Users\EloquentUser', | |||||
| ], | |||||
| 'roles' => [ | |||||
| 'model' => 'Cartalyst\Sentinel\Roles\EloquentRole', | |||||
| ], | |||||
| 'permissions' => [ | |||||
| 'class' => 'Cartalyst\Sentinel\Permissions\StandardPermissions', | |||||
| ], | |||||
| 'persistences' => [ | |||||
| 'model' => 'Cartalyst\Sentinel\Persistences\EloquentPersistence', | |||||
| 'single' => false, | |||||
| ], | |||||
| // Only throttle is enabled; activation can be re-added when a registration flow exists. | |||||
| 'checkpoints' => [ | |||||
| 'throttle', | |||||
| ], | |||||
| 'activations' => [ | |||||
| 'model' => 'Cartalyst\Sentinel\Activations\EloquentActivation', | |||||
| 'expires' => 259200, | |||||
| 'lottery' => [2, 100], | |||||
| ], | |||||
| 'reminders' => [ | |||||
| 'model' => 'Cartalyst\Sentinel\Reminders\EloquentReminder', | |||||
| 'expires' => 14400, | |||||
| 'lottery' => [2, 100], | |||||
| ], | |||||
| 'throttling' => [ | |||||
| 'model' => 'Cartalyst\Sentinel\Throttling\EloquentThrottle', | |||||
| 'global' => [ | |||||
| 'interval' => 900, | |||||
| 'thresholds' => [ | |||||
| 10 => 1, | |||||
| 20 => 2, | |||||
| 30 => 4, | |||||
| 40 => 8, | |||||
| 50 => 16, | |||||
| 60 => 32, | |||||
| ], | |||||
| ], | |||||
| 'ip' => [ | |||||
| 'interval' => 900, | |||||
| 'thresholds' => 5, | |||||
| ], | |||||
| 'user' => [ | |||||
| 'interval' => 900, | |||||
| 'thresholds' => 5, | |||||
| ], | |||||
| ], | |||||
| ]; | |||||
| @@ -94,9 +94,11 @@ function prepareSqliteDatabase(string $dsn): void | |||||
| } | } | ||||
| } | } | ||||
| function e(?string $value): string | |||||
| { | |||||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| if (!function_exists('e')) { | |||||
| function e(?string $value): string | |||||
| { | |||||
| return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); | |||||
| } | |||||
| } | } | ||||
| function asset(string $path): string | function asset(string $path): string | ||||
| @@ -0,0 +1,99 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| use Core\Database; | |||||
| use Core\Migration; | |||||
| return new class extends Migration | |||||
| { | |||||
| public function up(Database $database): void | |||||
| { | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS users ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| email VARCHAR(255) NOT NULL UNIQUE, | |||||
| password VARCHAR(255) NOT NULL, | |||||
| permissions TEXT, | |||||
| last_login DATETIME, | |||||
| first_name VARCHAR(255), | |||||
| last_name VARCHAR(255), | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME | |||||
| ) | |||||
| '); | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS roles ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| slug VARCHAR(255) NOT NULL UNIQUE, | |||||
| name VARCHAR(255) NOT NULL, | |||||
| permissions TEXT, | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME | |||||
| ) | |||||
| '); | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS role_users ( | |||||
| user_id INTEGER NOT NULL, | |||||
| role_id INTEGER NOT NULL, | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME, | |||||
| PRIMARY KEY (user_id, role_id) | |||||
| ) | |||||
| '); | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS persistences ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| user_id INTEGER NOT NULL, | |||||
| code VARCHAR(255) NOT NULL UNIQUE, | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME | |||||
| ) | |||||
| '); | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS activations ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| user_id INTEGER NOT NULL, | |||||
| code VARCHAR(255) NOT NULL, | |||||
| completed INTEGER NOT NULL DEFAULT 0, | |||||
| completed_at DATETIME, | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME | |||||
| ) | |||||
| '); | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS reminders ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| user_id INTEGER NOT NULL, | |||||
| code VARCHAR(255) NOT NULL, | |||||
| completed INTEGER NOT NULL DEFAULT 0, | |||||
| completed_at DATETIME, | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME | |||||
| ) | |||||
| '); | |||||
| $database->execute(' | |||||
| CREATE TABLE IF NOT EXISTS throttle ( | |||||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |||||
| user_id INTEGER, | |||||
| type VARCHAR(255) NOT NULL, | |||||
| ip VARCHAR(255), | |||||
| created_at DATETIME, | |||||
| updated_at DATETIME | |||||
| ) | |||||
| '); | |||||
| } | |||||
| public function down(Database $database): void | |||||
| { | |||||
| foreach (['throttle', 'reminders', 'activations', 'persistences', 'role_users', 'roles', 'users'] as $table) { | |||||
| $database->execute("DROP TABLE IF EXISTS {$table}"); | |||||
| } | |||||
| } | |||||
| }; | |||||
| @@ -466,3 +466,97 @@ code { | |||||
| font-size: 2.5rem; | font-size: 2.5rem; | ||||
| } | } | ||||
| } | } | ||||
| /* ── Auth / Login ─────────────────────────────────────── */ | |||||
| .auth-wrap { | |||||
| display: flex; | |||||
| justify-content: center; | |||||
| align-items: flex-start; | |||||
| padding: 2rem 0; | |||||
| } | |||||
| .auth-card { | |||||
| width: 100%; | |||||
| max-width: 460px; | |||||
| background: var(--surface); | |||||
| border: 1px solid var(--surface-border); | |||||
| box-shadow: var(--shadow-card); | |||||
| border-radius: 2rem; | |||||
| padding: 2.5rem 2.5rem 2rem; | |||||
| display: grid; | |||||
| gap: 1.5rem; | |||||
| } | |||||
| .auth-card-header { | |||||
| display: grid; | |||||
| gap: 0.4rem; | |||||
| } | |||||
| .auth-card-title { | |||||
| margin: 0; | |||||
| font-size: 2rem; | |||||
| letter-spacing: -0.04em; | |||||
| line-height: 1.1; | |||||
| } | |||||
| .auth-card-subtitle { | |||||
| margin: 0; | |||||
| color: var(--text-secondary); | |||||
| font-size: 0.97rem; | |||||
| line-height: 1.6; | |||||
| } | |||||
| .auth-form { | |||||
| display: grid; | |||||
| gap: 1.1rem; | |||||
| } | |||||
| .auth-remember { | |||||
| display: flex; | |||||
| align-items: center; | |||||
| gap: 0.55rem; | |||||
| font-size: 0.93rem; | |||||
| color: var(--text-secondary); | |||||
| cursor: pointer; | |||||
| } | |||||
| .auth-remember input[type="checkbox"] { | |||||
| width: 1rem; | |||||
| height: 1rem; | |||||
| accent-color: var(--accent); | |||||
| cursor: pointer; | |||||
| } | |||||
| .auth-submit { | |||||
| width: 100%; | |||||
| justify-content: center; | |||||
| padding: 0.95rem; | |||||
| font-size: 1rem; | |||||
| margin-top: 0.25rem; | |||||
| } | |||||
| .auth-nav-form { | |||||
| display: inline; | |||||
| margin: 0; | |||||
| padding: 0; | |||||
| } | |||||
| .auth-nav-btn { | |||||
| background: none; | |||||
| border: none; | |||||
| padding: 0.7rem 1rem; | |||||
| font: inherit; | |||||
| font-weight: 600; | |||||
| color: var(--text-secondary); | |||||
| border-radius: 999px; | |||||
| cursor: pointer; | |||||
| transition: background-color 160ms ease, color 160ms ease, transform 160ms ease; | |||||
| } | |||||
| .auth-nav-btn:hover, | |||||
| .auth-nav-btn:focus-visible { | |||||
| color: var(--accent-strong); | |||||
| background: rgba(29, 122, 109, 0.12); | |||||
| transform: translateY(-1px); | |||||
| } | |||||
| @@ -11,6 +11,8 @@ use Core\Router; | |||||
| ensureSessionStarted(); | ensureSessionStarted(); | ||||
| require_once __DIR__ . '/../bootstrap/sentinel.php'; | |||||
| $app = new App(); | $app = new App(); | ||||
| $router = new Router(); | $router = new Router(); | ||||
| @@ -3,6 +3,11 @@ | |||||
| declare(strict_types=1); | declare(strict_types=1); | ||||
| use App\Controllers\HomeController; | use App\Controllers\HomeController; | ||||
| use App\Controllers\AuthController; | |||||
| $router->get('/', [HomeController::class, 'index']); | $router->get('/', [HomeController::class, 'index']); | ||||
| $router->get('/users/{id}', [HomeController::class, 'user']); | $router->get('/users/{id}', [HomeController::class, 'user']); | ||||
| $router->get('/login', [AuthController::class, 'showLogin']); | |||||
| $router->post('/login', [AuthController::class, 'login']); | |||||
| $router->post('/logout', [AuthController::class, 'logout']); | |||||
| @@ -0,0 +1,19 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| require __DIR__ . '/../vendor/autoload.php'; | |||||
| ensureSessionStarted(); | |||||
| require __DIR__ . '/../bootstrap/sentinel.php'; | |||||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||||
| $user = Sentinel::registerAndActivate([ | |||||
| 'email' => 'admin@example.com', | |||||
| 'password' => 'secret123', | |||||
| 'first_name' => 'Admin', | |||||
| 'last_name' => 'User', | |||||
| ]); | |||||
| echo $user ? 'User created: ' . $user->email . PHP_EOL : 'Failed' . PHP_EOL; | |||||
Powered by TurnKey Linux.