| @@ -4,7 +4,17 @@ | |||
| "Bash(Get-ChildItem -Path \"c:\\\\Development\\\\PHP\\\\PHP-MVC-TERRITORY\" -Force)", | |||
| "Bash(Select-Object Mode, Name)", | |||
| "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); | |||
| use Cartalyst\Sentinel\Native\Facades\Sentinel; | |||
| $navigationItems = [ | |||
| ['label' => 'Home', 'href' => '/'], | |||
| ['label' => 'Example JSON', 'href' => '/users/123'], | |||
| @@ -9,6 +11,8 @@ $navigationItems = [ | |||
| $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); | |||
| $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : '/'; | |||
| $currentUser = Sentinel::check(); | |||
| ?> | |||
| <!DOCTYPE html> | |||
| <html lang="en"> | |||
| @@ -37,6 +41,15 @@ $currentPath = is_string($currentPath) && $currentPath !== '' ? $currentPath : ' | |||
| <?= e($item['label']) ?> | |||
| </a> | |||
| <?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> | |||
| </div> | |||
| </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-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 | |||
| @@ -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; | |||
| } | |||
| } | |||
| /* ── 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(); | |||
| require_once __DIR__ . '/../bootstrap/sentinel.php'; | |||
| $app = new App(); | |||
| $router = new Router(); | |||
| @@ -3,6 +3,11 @@ | |||
| declare(strict_types=1); | |||
| use App\Controllers\HomeController; | |||
| use App\Controllers\AuthController; | |||
| $router->get('/', [HomeController::class, 'index']); | |||
| $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.