From 7c0625542ca614abc0f7ea38ff13da99b4cbdafe Mon Sep 17 00:00:00 2001 From: Zack Warren Date: Wed, 12 Aug 2026 11:26:50 -0600 Subject: [PATCH 01/13] Update Composer dependencies --- .../laravel-best-practices/rules/security.md | 2 +- .../laravel-best-practices/rules/security.md | 2 +- .../laravel-best-practices/rules/security.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- composer.json | 22 +++++++++---------- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md index 2d7200c..447e1b6 100644 --- a/.agents/skills/laravel-best-practices/rules/security.md +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. +Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. Incorrect: ```blade diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md index 2d7200c..447e1b6 100644 --- a/.claude/skills/laravel-best-practices/rules/security.md +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. +Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. Incorrect: ```blade diff --git a/.github/skills/laravel-best-practices/rules/security.md b/.github/skills/laravel-best-practices/rules/security.md index 2d7200c..447e1b6 100644 --- a/.github/skills/laravel-best-practices/rules/security.md +++ b/.github/skills/laravel-best-practices/rules/security.md @@ -90,7 +90,7 @@ Correct: ## CSRF Protection -Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied. +Include `@csrf` in all POST/PUT/DELETE Blade forms. Inertia doesn't use `@csrf`; its HTTP client sends the `XSRF-TOKEN` cookie back as the `X-XSRF-TOKEN` header, which Laravel accepts in place of the `_token` field. Incorrect: ```blade diff --git a/AGENTS.md b/AGENTS.md index 29201e6..74ddab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac ## Project Rules -- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. - Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. ## Artisan diff --git a/CLAUDE.md b/CLAUDE.md index 29201e6..74ddab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac ## Project Rules -- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. - Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. ## Artisan diff --git a/composer.json b/composer.json index e188f99..acee097 100644 --- a/composer.json +++ b/composer.json @@ -12,11 +12,11 @@ "php": "^8.4", "inertiajs/inertia-laravel": "^3.3.1", "laravel/chisel": "^0.1.1", - "laravel/fortify": "^1.37.3", - "laravel/framework": "^13.24.0", - "laravel/mcp": "^0.9.1", - "laravel/octane": "^2.18.0", - "laravel/passport": "^13.7.5", + "laravel/fortify": "^1.38.0", + "laravel/framework": "^13.25.0", + "laravel/mcp": "^0.9.3", + "laravel/octane": "^2.19.0", + "laravel/passport": "^13.7.6", "laravel/tinker": "^3.0.2", "laravel/wayfinder": "^0.1.21" }, @@ -25,17 +25,17 @@ "driftingly/rector-laravel": "^2.5.0", "fakerphp/faker": "^1.24.1", "larastan/larastan": "^3.10.0", - "laravel/boost": "^2.5.0", + "laravel/boost": "^2.5.3", "laravel/pail": "^1.2.7", - "laravel/pao": "^1.1.3", - "laravel/pint": "^1.30.3", + "laravel/pao": "^1.1.4", + "laravel/pint": "^1.30.5", "mockery/mockery": "^1.6.12", "nunomaduro/collision": "^8.9.5", - "pestphp/pest": "^5.0.3", - "pestphp/pest-plugin-browser": "^5.0.0", + "pestphp/pest": "^5.1.1", + "pestphp/pest-plugin-browser": "^5.0.1", "pestphp/pest-plugin-laravel": "^5.0.1", "projektgopher/whisky": "^0.7.4", - "rector/rector": "^2.6.1" + "rector/rector": "^2.6.2" }, "autoload": { "psr-4": { From ea0362acb175985e3522b550108d7d6c3ca90baa Mon Sep 17 00:00:00 2001 From: Zack Warren Date: Wed, 12 Aug 2026 11:34:08 -0600 Subject: [PATCH 02/13] Update Node dependencies --- package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 0828a40..ee19288 100644 --- a/package.json +++ b/package.json @@ -15,32 +15,32 @@ "devDependencies": { "@laravel/vite-plugin-wayfinder": "^0.1.7", "@tailwindcss/vite": "^4.3.3", - "@types/node": "^26.1.2", + "@types/node": "^26.2.0", "@vitejs/plugin-vue": "^6.0.8", "concurrently": "^10.0.4", - "oxfmt": "^0.62.0", - "oxlint": "^1.77.0", + "oxfmt": "^0.63.0", + "oxlint": "^1.78.0", "oxlint-tsgolint": "^7.0.2001", "playwright": "^1.62.1", - "shadcn-vue": "^2.8.1", + "shadcn-vue": "^2.8.2", "typescript": "^6.0.3", - "vite": "^8.2.0", + "vite": "^8.2.1", "vue-tsc": "^3.3.9" }, "dependencies": { "@inertiajs/vite": "^3.6.1", "@inertiajs/vue3": "^3.6.1", "@laravel/passkeys": "^0.2.0", - "@lucide/vue": "^1.28.0", + "@lucide/vue": "^1.31.0", "@vueuse/core": "^14.4.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "laravel-vite-plugin": "^3.1.3", - "reka-ui": "^2.10.1", + "laravel-vite-plugin": "^3.2.0", + "reka-ui": "^2.10.3", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", - "vue": "^3.5.40", + "vue": "^3.5.41", "vue-input-otp": "^0.3.2", "vue-sonner": "^2.0.9" }, From ed537b912d4e862d1d9c92fb52b709a5088d7720 Mon Sep 17 00:00:00 2001 From: Zack Warren Date: Wed, 12 Aug 2026 11:35:00 -0600 Subject: [PATCH 03/13] starter-kit-upgrade: adopt Laravel Multiplex Upstream: laravel/vue-starter-kit@bccddd6, laravel/vue-starter-kit@d167a29 Files updated (manual merge): composer.json, package.json Files kept as-is: app/Providers/AppServiceProvider.php (existing Octane dev command registration), concurrently (Windows fallback) --- composer.json | 2 +- package.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index acee097..f0c2374 100644 --- a/composer.json +++ b/composer.json @@ -70,7 +70,7 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan octane:start --watch\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "@php artisan dev" ], "lint": [ "pint --parallel", diff --git a/package.json b/package.json index ee19288..0c51ffc 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "vue-sonner": "^2.0.9" }, "optionalDependencies": { + "@laravel/multiplex": "^0.4.1", "@tailwindcss/oxide-linux-x64-gnu": "^4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "^4.3.3", "lightningcss-linux-x64-gnu": "^1.33.0", From d748e07ae735baeb6d243f6c7651fb720bdb0ec3 Mon Sep 17 00:00:00 2001 From: Zack Warren Date: Wed, 12 Aug 2026 11:51:39 -0600 Subject: [PATCH 04/13] starter-kit-upgrade: restore Chisel auth feature selection Upstream: e86668f, c1e23aa, d61b61d, 70f7ba3, 9753ec7, f491c6c Manually merged Chisel boundaries into the customized Fortify, teams, passkey, Vue, and Pest files. Preserve Passport, MCP, Octane, teams, and oxlint as mandatory starter features. Also remove local 2FA factory/model/test/request/response artifacts when 2FA is deselected and refresh Composer lock metadata after Chisel removes its own hook. --- .../Commands/InstallFeaturesCommand.php | 108 ++++++ .../Settings/SecurityController.php | 6 + app/Models/User.php | 2 + app/Providers/FortifyServiceProvider.php | 41 +++ chisel-paths.php | 34 ++ chisel.php | 319 ++++++++++++++++++ composer.json | 5 +- config/fortify.php | 14 + database/factories/UserFactory.php | 4 + resources/js/pages/Welcome.vue | 4 + resources/js/pages/auth/ConfirmPassword.vue | 4 + resources/js/pages/auth/Login.vue | 8 + resources/js/pages/settings/Profile.vue | 6 + resources/js/pages/settings/Security.vue | 20 +- resources/js/types/auth.ts | 4 + routes/settings.php | 6 + tests/Feature/Auth/AuthenticationTest.php | 9 + tests/Feature/Settings/SecurityTest.php | 60 ++-- 18 files changed, 632 insertions(+), 22 deletions(-) create mode 100644 app/Console/Commands/InstallFeaturesCommand.php create mode 100644 chisel-paths.php create mode 100644 chisel.php diff --git a/app/Console/Commands/InstallFeaturesCommand.php b/app/Console/Commands/InstallFeaturesCommand.php new file mode 100644 index 0000000..0829d66 --- /dev/null +++ b/app/Console/Commands/InstallFeaturesCommand.php @@ -0,0 +1,108 @@ +shouldDeferInstallerHooks()) { + return self::SUCCESS; + } + + if (! file_exists(base_path('chisel.php'))) { + return self::SUCCESS; + } + + /** @var Script $script */ + $script = require base_path('chisel.php'); + + $providedAnswers = $this->option('answers') === null + ? [] + : json_decode((string) $this->option('answers'), true, 512, JSON_THROW_ON_ERROR); + + $answers = $script + ->collectAnswers() + ->onQuestion(fn (Question $question): array => multiselect( + label: $question->label, + options: $question->options, + default: $question->default ?? [], + required: $question->required, + hint: $question->hint, + )) + ->interactive($this->input->isInteractive()) + ->withAnswers($providedAnswers); + + $skipNode = $this->shouldSkipNode(); + + if (! $skipNode) { + $this->installNodeDependencies(); + } + + $script->chisel($answers); + + if (! $skipNode) { + $this->buildAssets(); + } + + return self::SUCCESS; + } + + protected function shouldDeferInstallerHooks(): bool + { + if ($this->option('answers') !== null) { + return false; + } + + return $this->installerFlag('LARAVEL_INSTALLER_DEFER_HOOKS'); + } + + protected function shouldSkipNode(): bool + { + return $this->installerFlag('LARAVEL_INSTALLER_NO_NODE'); + } + + protected function installerFlag(string $name): bool + { + return filter_var( + Env::get($name, Request::server($name) ?? getenv($name)), + FILTER_VALIDATE_BOOL, + ); + } + + protected function installNodeDependencies(): void + { + $npm = Chisel::in(base_path())->npm(); + $packageManager = $npm->packageManager(); + + spin( + fn () => $npm->install(), + "Installing dependencies with {$packageManager->value}...", + ); + } + + protected function buildAssets(): void + { + $npm = Chisel::in(base_path())->npm(); + + spin( + fn () => $npm->run('build'), + 'Building assets...', + ); + } +} diff --git a/app/Http/Controllers/Settings/SecurityController.php b/app/Http/Controllers/Settings/SecurityController.php index 0d007d3..bcac28c 100644 --- a/app/Http/Controllers/Settings/SecurityController.php +++ b/app/Http/Controllers/Settings/SecurityController.php @@ -19,7 +19,10 @@ class SecurityController extends Controller public function edit(TwoFactorAuthenticationRequest $request): Response { $props = [ + /* @chisel-2fa */ 'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(), + /* @end-chisel-2fa */ + /* @chisel-passkeys */ 'canManagePasskeys' => Features::canManagePasskeys(), 'passkeys' => Features::canManagePasskeys() ? $request->user() @@ -37,15 +40,18 @@ public function edit(TwoFactorAuthenticationRequest $request): Response ->values() ->all() : [], + /* @end-chisel-passkeys */ 'passwordRules' => Password::defaults()->toPasswordRulesString(), ]; + /* @chisel-2fa */ if (Features::canManageTwoFactorAuthentication()) { $request->ensureStateIsValid(); $props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication(); $props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); } + /* @end-chisel-2fa */ return Inertia::render('settings/Security', $props); } diff --git a/app/Models/User.php b/app/Models/User.php index e6e76cf..484e6bd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -56,7 +56,9 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + /* @chisel-2fa */ 'two_factor_confirmed_at' => 'datetime', + /* @end-chisel-2fa */ ]; } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 7cfa22d..27661a5 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -2,13 +2,23 @@ namespace App\Providers; +/* @chisel-registration */ use App\Actions\Fortify\CreateNewUser; +/* @end-chisel-registration */ use App\Actions\Fortify\ResetUserPassword; use App\Http\Responses\LoginResponse; +/* @chisel-passkeys */ use App\Http\Responses\PasskeyLoginResponse; +/* @end-chisel-passkeys */ +/* @chisel-registration */ use App\Http\Responses\RegisterResponse; +/* @end-chisel-registration */ +/* @chisel-2fa */ use App\Http\Responses\TwoFactorLoginResponse; +/* @end-chisel-2fa */ +/* @chisel-email-verification */ use App\Http\Responses\VerifyEmailResponse; +/* @end-chisel-email-verification */ use App\Models\TeamInvitation; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Database\Query\Builder; @@ -18,13 +28,22 @@ use Illuminate\Support\Str; use Inertia\Inertia; use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; +/* @chisel-registration */ use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; +/* @end-chisel-registration */ +/* @chisel-2fa */ use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract; +/* @end-chisel-2fa */ +/* @chisel-email-verification */ use Laravel\Fortify\Contracts\VerifyEmailResponse as VerifyEmailResponseContract; +/* @end-chisel-email-verification */ use Laravel\Fortify\Features; use Laravel\Fortify\Fortify; +/* @chisel-passkeys */ use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract; +/* @end-chisel-passkeys */ + class FortifyServiceProvider extends ServiceProvider { /** @@ -34,10 +53,18 @@ class FortifyServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(LoginResponseContract::class, LoginResponse::class); + /* @chisel-passkeys */ $this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class); + /* @end-chisel-passkeys */ + /* @chisel-registration */ $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); + /* @end-chisel-registration */ + /* @chisel-2fa */ $this->app->singleton(TwoFactorLoginResponseContract::class, TwoFactorLoginResponse::class); + /* @end-chisel-2fa */ + /* @chisel-email-verification */ $this->app->singleton(VerifyEmailResponseContract::class, VerifyEmailResponse::class); + /* @end-chisel-email-verification */ } /** @@ -56,7 +83,9 @@ public function boot(): void private function configureActions(): void { Fortify::resetUserPasswordsUsing(ResetUserPassword::class); + /* @chisel-registration */ Fortify::createUsersUsing(CreateNewUser::class); + /* @end-chisel-registration */ } /** @@ -79,17 +108,25 @@ private function configureViews(): void 'status' => $request->session()->get('status'), ])); + /* @chisel-email-verification */ Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/VerifyEmail', [ 'status' => $request->session()->get('status'), ])); + /* @end-chisel-email-verification */ + /* @chisel-registration */ Fortify::registerView(fn (Request $request) => Inertia::render('auth/Register', [ 'teamInvitation' => $this->teamInvitation($request), ])); + /* @end-chisel-registration */ + /* @chisel-2fa */ Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/TwoFactorChallenge')); + /* @end-chisel-2fa */ + /* @chisel-password-confirmation */ Fortify::confirmPasswordView(fn () => Inertia::render('auth/ConfirmPassword')); + /* @end-chisel-password-confirmation */ } /** @@ -97,7 +134,9 @@ private function configureViews(): void */ private function configureRateLimiting(): void { + /* @chisel-2fa */ RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id'))); + /* @end-chisel-2fa */ RateLimiter::for('login', function (Request $request) { $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); @@ -105,6 +144,7 @@ private function configureRateLimiting(): void return Limit::perMinute(5)->by($throttleKey); }); + /* @chisel-passkeys */ RateLimiter::for('passkeys', function (Request $request) { $credentialId = $request->input('credential.id'); @@ -112,6 +152,7 @@ private function configureRateLimiting(): void ($credentialId ?: $request->session()->getId()).'|'.$request->ip(), ); }); + /* @end-chisel-passkeys */ } /** diff --git a/chisel-paths.php b/chisel-paths.php new file mode 100644 index 0000000..1df3025 --- /dev/null +++ b/chisel-paths.php @@ -0,0 +1,34 @@ + 'resources/js/pages/auth/Login.vue', + 'register' => 'resources/js/pages/auth/Register.vue', + 'welcome' => 'resources/js/pages/Welcome.vue', + 'profile' => 'resources/js/pages/settings/Profile.vue', + 'security' => 'resources/js/pages/settings/Security.vue', + 'verify_email' => 'resources/js/pages/auth/VerifyEmail.vue', + 'two_factor_challenge' => 'resources/js/pages/auth/TwoFactorChallenge.vue', + 'confirm_password' => 'resources/js/pages/auth/ConfirmPassword.vue', + 'auth_types' => 'resources/js/types/auth.ts', + + 'two_factor_files' => [ + 'resources/js/components/ManageTwoFactor.vue', + 'resources/js/components/TwoFactorSetupModal.vue', + 'resources/js/components/TwoFactorRecoveryCodes.vue', + 'resources/js/components/ui/input-otp/index.ts', + 'resources/js/components/ui/input-otp/InputOTP.vue', + 'resources/js/components/ui/input-otp/InputOTPGroup.vue', + 'resources/js/components/ui/input-otp/InputOTPSeparator.vue', + 'resources/js/components/ui/input-otp/InputOTPSlot.vue', + 'resources/js/composables/useTwoFactorAuth.ts', + ], + + 'two_factor_otp_package' => 'vue-input-otp', + + 'passkey_files' => [ + 'resources/js/components/PasskeyItem.vue', + 'resources/js/components/ManagePasskeys.vue', + 'resources/js/components/PasskeyRegister.vue', + 'resources/js/components/PasskeyVerify.vue', + ], +]; diff --git a/chisel.php b/chisel.php new file mode 100644 index 0000000..3cd5c35 --- /dev/null +++ b/chisel.php @@ -0,0 +1,319 @@ +run(function ($type, $line) use ($logger) { + $logger->line($line); + }); + + if ($process->isSuccessful()) { + $logger->success(implode(' ', $command)); + + return $process; + } + + $logger->error(implode(' ', $command)); + $logger->error('Error output: '.trim($process->getErrorOutput())); + $logger->error('Chisel: Your project may be in a partially-modified state — review the output above before continuing.'); + + return $process; + }, + ); + + if (! $process->isSuccessful()) { + exit($process->getExitCode()); + } +} + +function chiselSkipsNode(): bool +{ + return filter_var( + $_ENV['LARAVEL_INSTALLER_NO_NODE'] + ?? $_SERVER['LARAVEL_INSTALLER_NO_NODE'] + ?? getenv('LARAVEL_INSTALLER_NO_NODE'), + FILTER_VALIDATE_BOOL, + ); +} + +function chiselRemoveNpmPackages(Chisel $c, string ...$packages): void +{ + if (! chiselSkipsNode()) { + $c->npm()->remove(...$packages); + + return; + } + + foreach ($packages as $package) { + $c->file('package.json')->removeLinesContaining('"'.$package.'":'); + } +} + +/** + * Framework-specific filenames are supplied by the sibling chisel-paths.php + * that ships with each Inertia kit (React/Svelte/Vue). After build both files + * land in the project root. + * + * @var array{ + * login: string, + * register: string, + * welcome: string, + * profile: string, + * security: string, + * verify_email: string, + * two_factor_challenge: string, + * confirm_password: string, + * auth_types: string, + * two_factor_files: list, + * two_factor_otp_package: ?string, + * passkey_files: list, + * } $paths + */ +$paths = require __DIR__.'/chisel-paths.php'; + +return Chisel::script(__DIR__) + ->questions([ + Question::multiselect( + name: 'auth_features', + label: 'Which authentication features would you like to enable?', + options: [ + 'email-verification' => 'Email verification', + 'registration' => 'Registration', + '2fa' => 'Two-factor authentication', + 'passkeys' => 'Passkeys', + 'password-confirmation' => 'Password confirmation', + ], + default: ['email-verification', 'registration', '2fa', 'passkeys', 'password-confirmation'], + hint: 'Use space to select, enter to confirm.', + ), + ]) + ->selected( + 'auth_features', + 'registration', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + $paths['login'], + $paths['welcome'], + )->removeSectionMarkers('registration'); + }, + else: function (Chisel $c) use ($paths) { + $c->file('config/fortify.php')->removeSection('registration'); + + $c->files( + 'app/Providers/FortifyServiceProvider.php', + $paths['login'], + $paths['welcome'], + )->removeSection('registration'); + + $c->files( + 'app/Actions/Fortify/CreateNewUser.php', + 'app/Http/Responses/RegisterResponse.php', + $paths['register'], + 'tests/Feature/Auth/RegistrationTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + 'email-verification', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + $paths['profile'], + 'app/Providers/FortifyServiceProvider.php', + )->removeSectionMarkers('email-verification'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Illuminate\Contracts\Auth\MustVerifyEmail') + ->removeInterface('MustVerifyEmail'); + + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + $paths['profile'], + )->removeSection('email-verification'); + + $c->files( + 'app/Http/Responses/VerifyEmailResponse.php', + $paths['verify_email'], + 'tests/Feature/Auth/EmailVerificationTest.php', + 'tests/Feature/Auth/VerificationNotificationTest.php', + )->delete(); + }, + ) + ->selected( + 'auth_features', + '2fa', + then: function (Chisel $c) use ($paths) { + $c->files( + 'app/Models/User.php', + 'database/factories/UserFactory.php', + $paths['security'], + $paths['auth_types'], + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSectionMarkers('2fa'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Fortify\TwoFactorAuthenticatable') + ->removeTrait('TwoFactorAuthenticatable'); + + $c->files( + 'app/Models/User.php', + 'database/factories/UserFactory.php', + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['security'], + $paths['auth_types'], + )->removeSection('2fa'); + + $c->file('app/Models/User.php') + ->removeLinesContaining('@property string|null $two_factor_secret') + ->removeLinesContaining('@property string|null $two_factor_recovery_codes') + ->removeLinesContaining('@property Carbon|null $two_factor_confirmed_at') + ->replace(", 'two_factor_secret', 'two_factor_recovery_codes'", ''); + + $c->file('app/Http/Controllers/Settings/SecurityController.php') + ->replace( + 'use App\\Http\\Requests\\Settings\\TwoFactorAuthenticationRequest;', + 'use Illuminate\\Http\\Request;', + ) + ->replace( + 'edit(TwoFactorAuthenticationRequest $request)', + 'edit(Request $request)', + ); + + if ($paths['two_factor_otp_package'] !== null) { + chiselRemoveNpmPackages($c, $paths['two_factor_otp_package']); + } + + $c->files(...[ + $paths['two_factor_challenge'], + ...$paths['two_factor_files'], + 'database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php', + 'app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php', + 'app/Http/Responses/TwoFactorLoginResponse.php', + 'tests/Feature/Auth/TwoFactorChallengeTest.php', + ])->delete(); + }, + ) + ->selected( + 'auth_features', + 'passkeys', + then: function (Chisel $c) use ($paths) { + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'routes/settings.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['auth_types'], + $paths['security'], + $paths['login'], + $paths['confirm_password'], + )->removeSectionMarkers('passkeys'); + }, + else: function (Chisel $c) use ($paths) { + $c->php('app/Models/User.php') + ->removeImport('Laravel\Fortify\PasskeyAuthenticatable') + ->removeImport('Laravel\Fortify\Contracts\PasskeyUser') + ->removeTrait('PasskeyAuthenticatable') + ->removeInterface('PasskeyUser'); + + $c->files( + 'config/fortify.php', + 'app/Providers/FortifyServiceProvider.php', + 'app/Http/Controllers/Settings/SecurityController.php', + 'routes/settings.php', + 'tests/Feature/Auth/AuthenticationTest.php', + 'tests/Feature/Settings/SecurityTest.php', + $paths['auth_types'], + $paths['security'], + $paths['login'], + $paths['confirm_password'], + )->removeSection('passkeys'); + + chiselRemoveNpmPackages($c, '@laravel/passkeys'); + + $c->files(...[ + ...$paths['passkey_files'], + 'app/Http/Responses/PasskeyLoginResponse.php', + 'database/migrations/2024_01_01_000000_create_passkeys_table.php', + ])->delete(); + }, + ) + ->selected( + 'auth_features', + 'password-confirmation', + then: function (Chisel $c) { + $c->files( + 'app/Providers/FortifyServiceProvider.php', + 'routes/settings.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSectionMarkers('password-confirmation'); + }, + else: function (Chisel $c) use ($paths) { + $c->file('config/fortify.php') + ->replace("'confirmPassword' => true,", "'confirmPassword' => false,"); + + $c->files( + 'app/Providers/FortifyServiceProvider.php', + 'routes/settings.php', + 'tests/Feature/Settings/SecurityTest.php', + )->removeSection('password-confirmation'); + + $c->files( + $paths['confirm_password'], + 'tests/Feature/Auth/PasswordConfirmationTest.php', + )->delete(); + }, + ) + ->apply(function (Chisel $c): void { + chiselRun(['composer', 'lint'], 'Composer Lint'); + chiselRun(['php', 'artisan', 'wayfinder:generate', '--with-form', '--no-interaction'], 'Generate Wayfinder Resources'); + + if (! chiselSkipsNode()) { + $c->npm()->run('lint'); + $c->npm()->run('format'); + } + + $c->file('composer.json') + ->removeLinesContaining('"@php artisan install:features --ansi"'); + + if (file_exists(__DIR__.'/composer.lock')) { + chiselRun( + ['composer', 'update', '--lock', '--no-install', '--no-scripts', '--no-interaction'], + 'Refresh Composer Lock', + ); + } + + $c->files( + 'app/Console/Commands/InstallFeaturesCommand.php', + 'chisel.php', + 'chisel-paths.php', + )->delete(); + }); diff --git a/composer.json b/composer.json index f0c2374..363701e 100644 --- a/composer.json +++ b/composer.json @@ -106,6 +106,7 @@ "@php artisan package:discover --ansi" ], "post-update-cmd": [ + "@php artisan install:features --ansi", "@php artisan vendor:publish --tag=laravel-assets --ansi --force", "@php artisan boost:update --env=local --ansi" ], @@ -125,7 +126,9 @@ "laravel": { "dont-discover": [], "installer": { - "post-create-project": [] + "post-create-project": [ + "@php artisan install:features --ansi" + ] } } }, diff --git a/config/fortify.php b/config/fortify.php index 7bdde27..36f84ba 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -118,10 +118,15 @@ 'limiters' => [ 'login' => 'login', + /* @chisel-2fa */ 'two-factor' => 'two-factor', + /* @end-chisel-2fa */ + /* @chisel-passkeys */ 'passkeys' => 'passkeys', + /* @end-chisel-passkeys */ ], + /* @chisel-passkeys */ /* |-------------------------------------------------------------------------- | Register View Routes @@ -150,6 +155,7 @@ 'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')), 'timeout' => 60000, ], + /* @end-chisel-passkeys */ /* |-------------------------------------------------------------------------- @@ -163,17 +169,25 @@ */ 'features' => [ + /* @chisel-registration */ Features::registration(), + /* @end-chisel-registration */ Features::resetPasswords(), + /* @chisel-email-verification */ Features::emailVerification(), + /* @end-chisel-email-verification */ + /* @chisel-2fa */ Features::twoFactorAuthentication([ 'confirm' => true, 'confirmPassword' => true, // 'window' => 0 ]), + /* @end-chisel-2fa */ + /* @chisel-passkeys */ Features::passkeys([ 'confirmPassword' => true, ]), + /* @end-chisel-passkeys */ ], ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 7af5a10..1772a55 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -32,9 +32,11 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + /* @chisel-2fa */ 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, 'two_factor_confirmed_at' => null, + /* @end-chisel-2fa */ ]; } @@ -67,6 +69,7 @@ public function unverified(): static ]); } + /* @chisel-2fa */ /** * Indicate that the model has two-factor authentication configured. */ @@ -78,4 +81,5 @@ public function withTwoFactor(): static 'two_factor_confirmed_at' => now(), ]); } + /* @end-chisel-2fa */ } diff --git a/resources/js/pages/Welcome.vue b/resources/js/pages/Welcome.vue index b4aeb6e..bfb0a0a 100644 --- a/resources/js/pages/Welcome.vue +++ b/resources/js/pages/Welcome.vue @@ -2,7 +2,9 @@ import { Head, Link, usePage } from '@inertiajs/vue3'; import { computed } from 'vue'; import { dashboard, login } from '@/routes'; +/* @chisel-registration */ import { register } from '@/routes'; +/* @end-chisel-registration */ const page = usePage(); const dashboardUrl = computed(() => @@ -36,12 +38,14 @@ const dashboardUrl = computed(() => > Log in + Register + diff --git a/resources/js/pages/auth/ConfirmPassword.vue b/resources/js/pages/auth/ConfirmPassword.vue index 43961ed..c748a83 100644 --- a/resources/js/pages/auth/ConfirmPassword.vue +++ b/resources/js/pages/auth/ConfirmPassword.vue @@ -1,11 +1,13 @@