diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9e3c17505..086e35068 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,6 +5,7 @@ on: branches: - master - develop + - rlc_dev - '*.x' pull_request: schedule: @@ -17,9 +18,9 @@ jobs: strategy: fail-fast: true matrix: - stack: [blade, livewire, livewire-functional, react, vue, api] - laravel: [11, 12] - args: ["", --pest] + stack: [ blade, forge-default, livewire, livewire-functional, react, vue, api ] + laravel: [ 11, 12 ] + args: [ "", --pest ] include: - stack: vue args: --ssr --typescript diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index f757fe884..152cd5a52 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -14,22 +14,21 @@ use Symfony\Component\Finder\Finder; use Symfony\Component\Process\PhpExecutableFinder; use Symfony\Component\Process\Process; - use function Laravel\Prompts\confirm; use function Laravel\Prompts\multiselect; use function Laravel\Prompts\select; -#[AsCommand(name: 'breeze:install')] +#[AsCommand( name: 'breeze:install' )] class InstallCommand extends Command implements PromptsForMissingInput { - use InstallsApiStack, InstallsBladeStack, InstallsInertiaStacks, InstallsLivewireStack; + use InstallsApiStack, InstallsBladeStack, InstallsInertiaStacks, InstallsLivewireStack, InstallsForgeDefaultStack; /** * The name and signature of the console command. * * @var string */ - protected $signature = 'breeze:install {stack : The development stack that should be installed (blade,livewire,livewire-functional,react,vue,api)} + protected $signature = 'breeze:install {stack : The development stack that should be installed (blade,forge-default,livewire,livewire-functional,react,vue,api)} {--dark : Indicate that dark mode support should be installed} {--pest : Indicate that Pest should be installed} {--ssr : Indicates if Inertia SSR support should be installed} @@ -44,6 +43,80 @@ class InstallCommand extends Command implements PromptsForMissingInput */ protected $description = 'Install the Breeze controllers and resources'; + /** + * Update the dependencies in the "package.json" file. + * + * @param bool $dev + * + * @return void + */ + protected static function updateNodePackages( callable $callback, $dev = true ) + { + if ( ! file_exists( base_path( 'package.json' ) ) ) + { + return; + } + + $configurationKey = $dev ? 'devDependencies' : 'dependencies'; + + $packages = json_decode( file_get_contents( base_path( 'package.json' ) ), true ); + + $packages[ $configurationKey ] = $callback( + array_key_exists( $configurationKey, $packages ) ? $packages[ $configurationKey ] : [], + $configurationKey + ); + + ksort( $packages[ $configurationKey ] ); + + file_put_contents( + base_path( 'package.json' ), + json_encode( $packages, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) . PHP_EOL + ); + } + + /** + * Update the scripts in the "package.json" file. + * + * @return void + */ + protected static function updateNodeScripts( callable $callback ) + { + if ( ! file_exists( base_path( 'package.json' ) ) ) + { + return; + } + + $content = json_decode( file_get_contents( base_path( 'package.json' ) ), true ); + + $content[ 'scripts' ] = $callback( + array_key_exists( 'scripts', $content ) ? $content[ 'scripts' ] : [] + ); + + file_put_contents( + base_path( 'package.json' ), + json_encode( $content, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT ) . PHP_EOL + ); + } + + /** + * Delete the "node_modules" directory and remove the associated lock files. + * + * @return void + */ + protected static function flushNodeModules() + { + tap( new Filesystem, function ( $files ) { + $files->deleteDirectory( base_path( 'node_modules' ) ); + + $files->delete( base_path( 'pnpm-lock.yaml' ) ); + $files->delete( base_path( 'yarn.lock' ) ); + $files->delete( base_path( 'bun.lock' ) ); + $files->delete( base_path( 'bun.lockb' ) ); + $files->delete( base_path( 'deno.lock' ) ); + $files->delete( base_path( 'package-lock.json' ) ); + } ); + } + /** * Execute the console command. * @@ -51,25 +124,81 @@ class InstallCommand extends Command implements PromptsForMissingInput */ public function handle() { - if ($this->argument('stack') === 'vue') { + if ( $this->argument( 'stack' ) === 'vue' ) + { return $this->installInertiaVueStack(); - } elseif ($this->argument('stack') === 'react') { + } + elseif ( $this->argument( 'stack' ) === 'react' ) + { return $this->installInertiaReactStack(); - } elseif ($this->argument('stack') === 'api') { + } + elseif ( $this->argument( 'stack' ) === 'api' ) + { return $this->installApiStack(); - } elseif ($this->argument('stack') === 'blade') { + } + elseif ( $this->argument( 'stack' ) === 'blade' ) + { return $this->installBladeStack(); - } elseif ($this->argument('stack') === 'livewire') { + } + elseif ( $this->argument( 'stack' ) === 'forge-default' ) + { + return $this->installForgeDefaultStack(); + } + elseif ( $this->argument( 'stack' ) === 'livewire' ) + { return $this->installLivewireStack(); - } elseif ($this->argument('stack') === 'livewire-functional') { - return $this->installLivewireStack(true); + } + elseif ( $this->argument( 'stack' ) === 'livewire-functional' ) + { + return $this->installLivewireStack( true ); } - $this->components->error('Invalid stack. Supported stacks are [blade], [livewire], [livewire-functional], [react], [vue], and [api].'); + $this->components->error( 'Invalid stack. Supported stacks are [blade], [livewire], [livewire-functional], [react], [vue], and [api].' ); return 1; } + /** + * Install the node modules + * + * @return void + */ + protected function installNodeModules() + { + $this->components->info( 'Installing and building Node dependencies.' ); + + if ( file_exists( base_path( 'pnpm-lock.yaml' ) ) ) + { + $this->runCommands( [ 'pnpm install', 'pnpm run build' ] ); + } + elseif ( file_exists( base_path( 'yarn.lock' ) ) ) + { + $this->runCommands( [ 'yarn install', 'yarn run build' ] ); + } + elseif ( file_exists( base_path( 'bun.lock' ) ) || file_exists( base_path( 'bun.lockb' ) ) ) + { + $this->runCommands( [ 'bun install', 'bun run build' ] ); + } + elseif ( file_exists( base_path( 'deno.lock' ) ) ) + { + $this->runCommands( [ 'deno install', 'deno task build' ] ); + } + else + { + $this->runCommands( [ 'npm install', 'npm run build' ] ); + } + } + + /** + * Append content to an existing file. + * + * @return void + */ + protected function appendToFile( string $contentToAppend, string $filePath ) + { + file_put_contents( $filePath, $contentToAppend, FILE_APPEND | LOCK_EX ); + } + /** * Install Breeze's tests. * @@ -77,29 +206,35 @@ public function handle() */ protected function installTests() { - (new Filesystem)->ensureDirectoryExists(base_path('tests/Feature')); + ( new Filesystem )->ensureDirectoryExists( base_path( 'tests/Feature' ) ); - $stubStack = match ($this->argument('stack')) { + $stubStack = match ( $this->argument( 'stack' ) ) + { 'api' => 'api', 'livewire' => 'livewire-common', 'livewire-functional' => 'livewire-common', default => 'default', }; - if ($this->option('pest') || $this->isUsingPest()) { - if ($this->hasComposerPackage('phpunit/phpunit')) { - $this->removeComposerPackages(['phpunit/phpunit'], true); + if ( $this->option( 'pest' ) || $this->isUsingPest() ) + { + if ( $this->hasComposerPackage( 'phpunit/phpunit' ) ) + { + $this->removeComposerPackages( [ 'phpunit/phpunit' ], true ); } - if (! $this->requireComposerPackages(['pestphp/pest', 'pestphp/pest-plugin-laravel'], true)) { + if ( ! $this->requireComposerPackages( [ 'pestphp/pest', 'pestphp/pest-plugin-laravel' ], true ) ) + { return false; } - (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/'.$stubStack.'/pest-tests/Feature', base_path('tests/Feature')); - (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/'.$stubStack.'/pest-tests/Unit', base_path('tests/Unit')); - (new Filesystem)->copy(__DIR__.'/../../stubs/'.$stubStack.'/pest-tests/Pest.php', base_path('tests/Pest.php')); - } else { - (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/'.$stubStack.'/tests/Feature', base_path('tests/Feature')); + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/' . $stubStack . '/pest-tests/Feature', base_path( 'tests/Feature' ) ); + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/' . $stubStack . '/pest-tests/Unit', base_path( 'tests/Unit' ) ); + ( new Filesystem )->copy( __DIR__ . '/../../stubs/' . $stubStack . '/pest-tests/Pest.php', base_path( 'tests/Pest.php' ) ); + } + else + { + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/' . $stubStack . '/tests/Feature', base_path( 'tests/Feature' ) ); } return true; @@ -108,19 +243,20 @@ protected function installTests() /** * Install the given middleware names into the application. * - * @param array|string $name - * @param string $group - * @param string $modifier + * @param array|string $name + * @param string $group + * @param string $modifier + * * @return void */ - protected function installMiddleware($names, $group = 'web', $modifier = 'append') + protected function installMiddleware( $names, $group = 'web', $modifier = 'append' ) { - $bootstrapApp = file_get_contents(base_path('bootstrap/app.php')); + $bootstrapApp = file_get_contents( base_path( 'bootstrap/app.php' ) ); - $names = collect(Arr::wrap($names)) - ->filter(fn ($name) => ! Str::contains($bootstrapApp, $name)) - ->whenNotEmpty(function ($names) use ($bootstrapApp, $group, $modifier) { - $names = $names->map(fn ($name) => "$name")->implode(','.PHP_EOL.' '); + $names = collect( Arr::wrap( $names ) ) + ->filter( fn( $name ) => ! Str::contains( $bootstrapApp, $name ) ) + ->whenNotEmpty( function ( $names ) use ( $bootstrapApp, $group, $modifier ) { + $names = $names->map( fn( $name ) => "$name" )->implode( ',' . PHP_EOL . ' ' ); $stubs = [ '->withMiddleware(function (Middleware $middleware) {', @@ -129,33 +265,34 @@ protected function installMiddleware($names, $group = 'web', $modifier = 'append $bootstrapApp = str_replace( $stubs, - collect($stubs)->transform(fn ($stub) => $stub - .PHP_EOL." \$middleware->$group($modifier: [" - .PHP_EOL." $names," - .PHP_EOL.' ]);' - .PHP_EOL + collect( $stubs )->transform( fn( $stub ) => $stub + . PHP_EOL . " \$middleware->$group($modifier: [" + . PHP_EOL . " $names," + . PHP_EOL . ' ]);' + . PHP_EOL )->all(), $bootstrapApp, ); - file_put_contents(base_path('bootstrap/app.php'), $bootstrapApp); - }); + file_put_contents( base_path( 'bootstrap/app.php' ), $bootstrapApp ); + } ); } /** * Install the given middleware aliases into the application. * - * @param array $aliases + * @param array $aliases + * * @return void */ - protected function installMiddlewareAliases($aliases) + protected function installMiddlewareAliases( $aliases ) { - $bootstrapApp = file_get_contents(base_path('bootstrap/app.php')); + $bootstrapApp = file_get_contents( base_path( 'bootstrap/app.php' ) ); - $aliases = collect($aliases) - ->filter(fn ($alias) => ! Str::contains($bootstrapApp, $alias)) - ->whenNotEmpty(function ($aliases) use ($bootstrapApp) { - $aliases = $aliases->map(fn ($name, $alias) => "'$alias' => $name")->implode(','.PHP_EOL.' '); + $aliases = collect( $aliases ) + ->filter( fn( $alias ) => ! Str::contains( $bootstrapApp, $alias ) ) + ->whenNotEmpty( function ( $aliases ) use ( $bootstrapApp ) { + $aliases = $aliases->map( fn( $name, $alias ) => "'$alias' => $name" )->implode( ',' . PHP_EOL . ' ' ); $stubs = [ '->withMiddleware(function (Middleware $middleware) {', @@ -164,169 +301,104 @@ protected function installMiddlewareAliases($aliases) $bootstrapApp = str_replace( $stubs, - collect($stubs)->transform(fn ($stub) => $stub - .PHP_EOL.' $middleware->alias([' - .PHP_EOL." $aliases," - .PHP_EOL.' ]);' - .PHP_EOL + collect( $stubs )->transform( fn( $stub ) => $stub + . PHP_EOL . ' $middleware->alias([' + . PHP_EOL . " $aliases," + . PHP_EOL . ' ]);' + . PHP_EOL )->all(), $bootstrapApp, ); - file_put_contents(base_path('bootstrap/app.php'), $bootstrapApp); - }); + file_put_contents( base_path( 'bootstrap/app.php' ), $bootstrapApp ); + } ); } /** * Determine if the given Composer package is installed. * - * @param string $package + * @param string $package + * * @return bool */ - protected function hasComposerPackage($package) + protected function hasComposerPackage( $package ) { - $packages = json_decode(file_get_contents(base_path('composer.json')), true); + $packages = json_decode( file_get_contents( base_path( 'composer.json' ) ), true ); - return array_key_exists($package, $packages['require'] ?? []) - || array_key_exists($package, $packages['require-dev'] ?? []); + return array_key_exists( $package, $packages[ 'require' ] ?? [] ) + || array_key_exists( $package, $packages[ 'require-dev' ] ?? [] ); } /** * Installs the given Composer Packages into the application. * - * @param bool $asDev + * @param bool $asDev + * * @return bool */ - protected function requireComposerPackages(array $packages, $asDev = false) + protected function requireComposerPackages( array $packages, $asDev = false ) { - $composer = $this->option('composer'); + $composer = $this->option( 'composer' ); - if ($composer !== 'global') { - $command = ['php', $composer, 'require']; + if ( $composer !== 'global' ) + { + $command = [ 'php', $composer, 'require' ]; } $command = array_merge( - $command ?? ['composer', 'require'], + $command ?? [ 'composer', 'require' ], $packages, - $asDev ? ['--dev'] : [], + $asDev ? [ '--dev' ] : [], ); - return (new Process($command, base_path(), ['COMPOSER_MEMORY_LIMIT' => '-1'])) - ->setTimeout(null) - ->run(function ($type, $output) { - $this->output->write($output); - }) === 0; + return ( new Process( $command, base_path(), [ 'COMPOSER_MEMORY_LIMIT' => '-1' ] ) ) + ->setTimeout( null ) + ->run( function ( $type, $output ) { + $this->output->write( $output ); + } ) === 0; } /** * Removes the given Composer Packages from the application. * - * @param bool $asDev + * @param bool $asDev + * * @return bool */ - protected function removeComposerPackages(array $packages, $asDev = false) + protected function removeComposerPackages( array $packages, $asDev = false ) { - $composer = $this->option('composer'); + $composer = $this->option( 'composer' ); - if ($composer !== 'global') { - $command = ['php', $composer, 'remove']; + if ( $composer !== 'global' ) + { + $command = [ 'php', $composer, 'remove' ]; } $command = array_merge( - $command ?? ['composer', 'remove'], + $command ?? [ 'composer', 'remove' ], $packages, - $asDev ? ['--dev'] : [], + $asDev ? [ '--dev' ] : [], ); - return (new Process($command, base_path(), ['COMPOSER_MEMORY_LIMIT' => '-1'])) - ->setTimeout(null) - ->run(function ($type, $output) { - $this->output->write($output); - }) === 0; - } - - /** - * Update the dependencies in the "package.json" file. - * - * @param bool $dev - * @return void - */ - protected static function updateNodePackages(callable $callback, $dev = true) - { - if (! file_exists(base_path('package.json'))) { - return; - } - - $configurationKey = $dev ? 'devDependencies' : 'dependencies'; - - $packages = json_decode(file_get_contents(base_path('package.json')), true); - - $packages[$configurationKey] = $callback( - array_key_exists($configurationKey, $packages) ? $packages[$configurationKey] : [], - $configurationKey - ); - - ksort($packages[$configurationKey]); - - file_put_contents( - base_path('package.json'), - json_encode($packages, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).PHP_EOL - ); - } - - /** - * Update the scripts in the "package.json" file. - * - * @return void - */ - protected static function updateNodeScripts(callable $callback) - { - if (! file_exists(base_path('package.json'))) { - return; - } - - $content = json_decode(file_get_contents(base_path('package.json')), true); - - $content['scripts'] = $callback( - array_key_exists('scripts', $content) ? $content['scripts'] : [] - ); - - file_put_contents( - base_path('package.json'), - json_encode($content, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).PHP_EOL - ); - } - - /** - * Delete the "node_modules" directory and remove the associated lock files. - * - * @return void - */ - protected static function flushNodeModules() - { - tap(new Filesystem, function ($files) { - $files->deleteDirectory(base_path('node_modules')); - - $files->delete(base_path('pnpm-lock.yaml')); - $files->delete(base_path('yarn.lock')); - $files->delete(base_path('bun.lock')); - $files->delete(base_path('bun.lockb')); - $files->delete(base_path('deno.lock')); - $files->delete(base_path('package-lock.json')); - }); + return ( new Process( $command, base_path(), [ 'COMPOSER_MEMORY_LIMIT' => '-1' ] ) ) + ->setTimeout( null ) + ->run( function ( $type, $output ) { + $this->output->write( $output ); + } ) === 0; } /** * Replace a given string within a given file. * - * @param string $search - * @param string $replace - * @param string $path + * @param string $search + * @param string $replace + * @param string $path + * * @return void */ - protected function replaceInFile($search, $replace, $path) + protected function replaceInFile( $search, $replace, $path ) { - file_put_contents($path, str_replace($search, $replace, file_get_contents($path))); + file_put_contents( $path, str_replace( $search, $replace, file_get_contents( $path ) ) ); } /** @@ -336,34 +408,40 @@ protected function replaceInFile($search, $replace, $path) */ protected function phpBinary() { - if (function_exists('Illuminate\Support\php_binary')) { + if ( function_exists( 'Illuminate\Support\php_binary' ) ) + { return \Illuminate\Support\php_binary(); } - return (new PhpExecutableFinder)->find(false) ?: 'php'; + return ( new PhpExecutableFinder )->find( false ) ?: 'php'; } /** * Run the given commands. * - * @param array $commands + * @param array $commands + * * @return void */ - protected function runCommands($commands) + protected function runCommands( $commands ) { - $process = Process::fromShellCommandline(implode(' && ', $commands), null, null, null, null); + $process = Process::fromShellCommandline( implode( ' && ', $commands ), null, null, null, null ); - if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { - try { - $process->setTty(true); - } catch (RuntimeException $e) { - $this->output->writeln(' WARN '.$e->getMessage().PHP_EOL); + if ( '\\' !== DIRECTORY_SEPARATOR && file_exists( '/dev/tty' ) && is_readable( '/dev/tty' ) ) + { + try + { + $process->setTty( true ); + } + catch( RuntimeException $e ) + { + $this->output->writeln( ' WARN ' . $e->getMessage() . PHP_EOL ); } } - $process->run(function ($type, $line) { - $this->output->write(' '.$line); - }); + $process->run( function ( $type, $line ) { + $this->output->write( ' ' . $line ); + } ); } /** @@ -371,10 +449,11 @@ protected function runCommands($commands) * * @return void */ - protected function removeDarkClasses(Finder $finder) + protected function removeDarkClasses( Finder $finder ) { - foreach ($finder as $file) { - file_put_contents($file->getPathname(), preg_replace('/\sdark:[^\s"\']+/', '', $file->getContents())); + foreach ( $finder as $file ) + { + file_put_contents( $file->getPathname(), preg_replace( '/\sdark:[^\s"\']+/', '', $file->getContents() ) ); } } @@ -386,17 +465,18 @@ protected function removeDarkClasses(Finder $finder) protected function promptForMissingArgumentsUsing() { return [ - 'stack' => fn () => select( - label: 'Which Breeze stack would you like to install?', + 'stack' => fn() => select( + label : 'Which Breeze stack would you like to install?', options: [ - 'blade' => 'Blade with Alpine', - 'livewire' => 'Livewire (Volt Class API) with Alpine', - 'livewire-functional' => 'Livewire (Volt Functional API) with Alpine', - 'react' => 'React with Inertia', - 'vue' => 'Vue with Inertia', - 'api' => 'API only', - ], - scroll: 6, + 'forge-default' => 'Laravel Forge + Blade Views', + 'blade' => 'Blade with Alpine', + 'livewire' => 'Livewire (Volt Class API) with Alpine', + 'livewire-functional' => 'Livewire (Volt Functional API) with Alpine', + 'react' => 'React with Inertia', + 'vue' => 'Vue with Inertia', + 'api' => 'API only', + ], + scroll : 6, ), ]; } @@ -406,33 +486,36 @@ protected function promptForMissingArgumentsUsing() * * @return void */ - protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + protected function afterPromptingForMissingArguments( InputInterface $input, OutputInterface $output ) { - $stack = $input->getArgument('stack'); - - if (in_array($stack, ['react', 'vue'])) { - collect(multiselect( - label: 'Would you like any optional features?', - options: [ - 'dark' => 'Dark mode', - 'ssr' => 'Inertia SSR', - 'typescript' => 'TypeScript', - 'eslint' => 'ESLint with Prettier', - ], - hint: 'Use the space bar to select options.' - ))->each(fn ($option) => $input->setOption($option, true)); - } elseif (in_array($stack, ['blade', 'livewire', 'livewire-functional'])) { - $input->setOption('dark', confirm( - label: 'Would you like dark mode support?', + $stack = $input->getArgument( 'stack' ); + + if ( in_array( $stack, [ 'react', 'vue' ] ) ) + { + collect( multiselect( + label : 'Would you like any optional features?', + options: [ + 'dark' => 'Dark mode', + 'ssr' => 'Inertia SSR', + 'typescript' => 'TypeScript', + 'eslint' => 'ESLint with Prettier', + ], + hint : 'Use the space bar to select options.' + ) )->each( fn( $option ) => $input->setOption( $option, true ) ); + } + elseif ( in_array( $stack, [ 'blade', 'forge-default', 'livewire', 'livewire-functional' ] ) ) + { + $input->setOption( 'dark', confirm( + label : 'Would you like dark mode support?', default: false - )); + ) ); } - $input->setOption('pest', select( - label: 'Which testing framework do you prefer?', - options: ['Pest', 'PHPUnit'], - default: 'Pest', - ) === 'Pest'); + $input->setOption( 'pest', select( + label : 'Which testing framework do you prefer?', + options: [ 'Pest', 'PHPUnit' ], + default: 'Pest', + ) === 'Pest' ); } /** @@ -442,6 +525,6 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp */ protected function isUsingPest() { - return class_exists(\Pest\TestSuite::class); + return class_exists( \Pest\TestSuite::class ); } } diff --git a/src/Console/InstallsForgeDefaultStack.php b/src/Console/InstallsForgeDefaultStack.php new file mode 100644 index 000000000..0ff6e465b --- /dev/null +++ b/src/Console/InstallsForgeDefaultStack.php @@ -0,0 +1,90 @@ +hasComposerPackage( 'laravel/fortify' ) ) + { + $this->requireComposerPackages( [ 'laravel/fortify' ], false ); + } + $this->runCommands( [ 'php artisan fortify:install' ] ); + + // NPM Packages... + $this->updateNodePackages( function ( $packages ) { + return [ + "@tailwindcss/vite" => "^4.1.16", + "alpinejs" => "^3.15.2", + "tailwindcss" => "^4.1.16", + ] + $packages; + } ); + + // Service Providers... + ( new Filesystem )->ensureDirectoryExists( app_path( 'Providers' ) ); + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/forge-default/app/Providers', app_path( 'Providers' ) ); + + // Controllers... + ( new Filesystem )->ensureDirectoryExists( app_path( 'Http/Controllers' ) ); + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/forge-default/app/Http/Controllers', app_path( 'Http/Controllers' ) ); + + // Views... + ( new Filesystem )->ensureDirectoryExists( resource_path( 'views' ) ); + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/forge-default/resources/views', resource_path( 'views' ) ); + + if ( ! $this->option( 'dark' ) ) + { + $this->removeDarkClasses( ( new Finder ) + ->in( resource_path( 'views' ) ) + ->name( '*.blade.php' ) + ->notPath( 'livewire/welcome/navigation.blade.php' ) + ->notName( 'welcome.blade.php' ) + ); + } + + // Components... + ( new Filesystem )->ensureDirectoryExists( app_path( 'View/Components' ) ); + ( new Filesystem )->copyDirectory( __DIR__ . '/../../stubs/forge-default/app/View/Components', app_path( 'View/Components' ) ); + + // Tests... + if ( ! $this->installTests() ) + { + return 1; + } + + // Routes... + $routesToAppend = file_get_contents( __DIR__ . '/../../stubs/forge-default/routes/web.php.append' ); + $this->appendToFile( $routesToAppend, base_path( 'routes/web.php' ) ); + + // Config... + copy( __DIR__ . '/../../stubs/forge-default/config/fortify.php', base_path( 'config/fortify.php' ) ); + + // "Dashboard" Route... + $this->replaceInFile( '/home', '/dashboard', resource_path( 'views/welcome.blade.php' ) ); + $this->replaceInFile( 'Home', 'Dashboard', resource_path( 'views/welcome.blade.php' ) ); + + // Tailwind / Vite... + copy( __DIR__ . '/../../stubs/forge-default/tailwind.config.js', base_path( 'tailwind.config.js' ) ); + copy( __DIR__ . '/../../stubs/forge-default/vite.config.js', base_path( 'vite.config.js' ) ); + $appCssToAppend = file_get_contents( __DIR__ . '/../../stubs/forge-default/resources/css/app.css.append' ); + $this->appendToFile( $appCssToAppend, base_path( 'resources/css/app.css' ) ); + copy( __DIR__ . '/../../stubs/forge-default/resources/js/theme.js', resource_path( 'js/theme.js' ) ); + $appJsToAppend = file_get_contents( __DIR__ . '/../../stubs/forge-default/resources/js/app.js.append' ); + $this->appendToFile( $appJsToAppend, base_path( 'resources/js/app.js' ) ); + + $this->installNodeModules(); + + $this->line( '' ); + $this->components->info( 'Breeze scaffolding installed successfully.' ); + } +} diff --git a/stubs/forge-default/app/Http/Controllers/ProfileController.php b/stubs/forge-default/app/Http/Controllers/ProfileController.php new file mode 100644 index 000000000..cc247cac5 --- /dev/null +++ b/stubs/forge-default/app/Http/Controllers/ProfileController.php @@ -0,0 +1,43 @@ + $request->user(), + ] ); + } + + /** + * Delete the user's account. + */ + public function destroy( Request $request ): RedirectResponse + { + $request->validateWithBag( 'userDeletion', [ + 'password' => [ 'required', 'current_password' ], + ] ); + + $user = $request->user(); + + Auth::logout(); + + $user->delete(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return Redirect::to( '/' ); + } +} diff --git a/stubs/forge-default/app/Providers/FortifyServiceProvider.php b/stubs/forge-default/app/Providers/FortifyServiceProvider.php new file mode 100644 index 000000000..b495bf165 --- /dev/null +++ b/stubs/forge-default/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,72 @@ +input( Fortify::username() ) ) . '|' . $request->ip() ); + + return Limit::perMinute( 5 )->by( $throttleKey ); + } ); + + RateLimiter::for( 'two-factor', function ( Request $request ) { + return Limit::perMinute( 5 )->by( $request->session()->get( 'login.id' ) ); + } ); + + Fortify::registerView( function () { + return view( 'auth.register' ); + } ); + + Fortify::loginView( function () { + return view( 'auth.login' ); + } ); + + Fortify::requestPasswordResetLinkView( function () { + return view( 'auth.forgot-password' ); + } ); + + Fortify::resetPasswordView( function ( Request $request ) { + return view( 'auth.reset-password', [ 'request' => $request ] ); + } ); + + Fortify::confirmPasswordView( function () { + return view( 'auth.confirm-password' ); + } ); + + Fortify::verifyEmailView( function () { + return view( 'auth.verify-email' ); + } ); + } +} diff --git a/stubs/forge-default/app/View/Components/AppLayout.php b/stubs/forge-default/app/View/Components/AppLayout.php new file mode 100644 index 000000000..de0d46f58 --- /dev/null +++ b/stubs/forge-default/app/View/Components/AppLayout.php @@ -0,0 +1,17 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => [ 'web' ], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features or you can even remove all of these if you need to. + | + */ + + 'features' => [ + Features::registration(), + Features::resetPasswords(), + Features::emailVerification(), + Features::updateProfileInformation(), + Features::updatePasswords(), + // Features::twoFactorAuthentication([ + // 'confirm' => true, + // 'confirmPassword' => true, + // // 'window' => 0, + // ]), + ], + +]; diff --git a/stubs/forge-default/pest-tests/Feature/Auth/AuthenticationTest.php b/stubs/forge-default/pest-tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 000000000..a272b9d58 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,41 @@ +get('/login'); + + $response->assertStatus(200); +}); + +test('users can authenticate using the login screen', function () { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); +}); + +test('users can not authenticate with invalid password', function () { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); +}); + +test('users can logout', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); +}); diff --git a/stubs/forge-default/pest-tests/Feature/Auth/EmailVerificationTest.php b/stubs/forge-default/pest-tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 000000000..f282dff04 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,46 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); +}); + +test('email can be verified', function () { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); +}); + +test('email is not verified with invalid hash', function () { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + expect($user->fresh()->hasVerifiedEmail())->toBeFalse(); +}); diff --git a/stubs/forge-default/pest-tests/Feature/Auth/PasswordConfirmationTest.php b/stubs/forge-default/pest-tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 000000000..8a42902e3 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,32 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); +}); + +test('password can be confirmed', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); +}); + +test('password is not confirmed with invalid password', function () { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); +}); diff --git a/stubs/forge-default/pest-tests/Feature/Auth/PasswordResetTest.php b/stubs/forge-default/pest-tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 000000000..0504276a6 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,60 @@ +get('/forgot-password'); + + $response->assertStatus(200); +}); + +test('reset password link can be requested', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); +}); + +test('reset password screen can be rendered', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); +}); + +test('password can be reset with valid token', function () { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); +}); diff --git a/stubs/forge-default/pest-tests/Feature/Auth/PasswordUpdateTest.php b/stubs/forge-default/pest-tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 000000000..e3d12780f --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,40 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); +}); + +test('correct password must be provided to update password', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrorsIn('updatePassword', 'current_password') + ->assertRedirect('/profile'); +}); diff --git a/stubs/forge-default/pest-tests/Feature/Auth/RegistrationTest.php b/stubs/forge-default/pest-tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 000000000..352ca7879 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,19 @@ +get('/register'); + + $response->assertStatus(200); +}); + +test('new users can register', function () { + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); +}); diff --git a/stubs/forge-default/pest-tests/Feature/ExampleTest.php b/stubs/forge-default/pest-tests/Feature/ExampleTest.php new file mode 100644 index 000000000..8b5843f49 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/ExampleTest.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); +}); diff --git a/stubs/forge-default/pest-tests/Feature/ProfileTest.php b/stubs/forge-default/pest-tests/Feature/ProfileTest.php new file mode 100644 index 000000000..153645807 --- /dev/null +++ b/stubs/forge-default/pest-tests/Feature/ProfileTest.php @@ -0,0 +1,85 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); +}); + +test('profile information can be updated', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); +}); + +test('email verification status is unchanged when the email address is unchanged', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); +}); + +test('user can delete their account', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->delete('/profile', [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); + + $this->assertGuest(); + $this->assertNull($user->fresh()); +}); + +test('correct password must be provided to delete account', function () { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->delete('/profile', [ + 'password' => 'wrong-password', + ]); + + $response + ->assertSessionHasErrorsIn('userDeletion', 'password') + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()); +}); diff --git a/stubs/forge-default/pest-tests/Pest.php b/stubs/forge-default/pest-tests/Pest.php new file mode 100644 index 000000000..40d096b52 --- /dev/null +++ b/stubs/forge-default/pest-tests/Pest.php @@ -0,0 +1,47 @@ +extend(Tests\TestCase::class) + ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) + ->in('Feature'); + +/* +|-------------------------------------------------------------------------- +| Expectations +|-------------------------------------------------------------------------- +| +| When you're writing tests, you often need to check that values meet certain conditions. The +| "expect()" function gives you access to a set of "expectations" methods that you can use +| to assert different things. Of course, you may extend the Expectation API at any time. +| +*/ + +expect()->extend('toBeOne', function () { + return $this->toBe(1); +}); + +/* +|-------------------------------------------------------------------------- +| Functions +|-------------------------------------------------------------------------- +| +| While Pest is very powerful out-of-the-box, you may have some testing code specific to your +| project that you don't want to repeat in every file. Here you can also expose helpers as +| global functions to help you to reduce the number of lines of code in your test files. +| +*/ + +function something() +{ + // .. +} diff --git a/stubs/forge-default/pest-tests/Unit/ExampleTest.php b/stubs/forge-default/pest-tests/Unit/ExampleTest.php new file mode 100644 index 000000000..44a4f337a --- /dev/null +++ b/stubs/forge-default/pest-tests/Unit/ExampleTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/stubs/forge-default/resources/css/app.css.append b/stubs/forge-default/resources/css/app.css.append new file mode 100644 index 000000000..4240c4758 --- /dev/null +++ b/stubs/forge-default/resources/css/app.css.append @@ -0,0 +1,24 @@ +/* -------- Laravel Breeze Start -------- */ + +/* Tailwind v4 custom dark variant */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Ensure browser color-scheme respects theme */ +:root { + color-scheme: light; +} + +.dark { + color-scheme: dark; +} + +/* Optional helper for smooth transitions */ +.transition-colors { + transition-property: color, background-color, border-color; + transition-duration: 200ms; +} + +@tailwind utilities; + +/* -------- Laravel Breeze Stop -------- */ + diff --git a/stubs/forge-default/resources/js/app.js.append b/stubs/forge-default/resources/js/app.js.append new file mode 100644 index 000000000..d65a40c7d --- /dev/null +++ b/stubs/forge-default/resources/js/app.js.append @@ -0,0 +1,12 @@ +// Laravel Breeze added the following + +import {toggleTheme} from './theme.js'; +import Alpine from 'alpinejs' + +const themeToggle = document.getElementById('theme-toggle'); +if (themeToggle) { + themeToggle.addEventListener('click', toggleTheme); +} + +window.Alpine = Alpine +Alpine.start() diff --git a/stubs/forge-default/resources/js/theme.js b/stubs/forge-default/resources/js/theme.js new file mode 100644 index 000000000..74cf7fe1b --- /dev/null +++ b/stubs/forge-default/resources/js/theme.js @@ -0,0 +1,22 @@ +// Immediately set theme on page load +(function () { + try { + const stored = localStorage.getItem('theme'); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + + const isDark = stored === 'dark' || (!stored && prefersDark); + + document.documentElement.classList.toggle('dark', isDark); + document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; + } catch (e) { + console.warn('Theme initialization failed:', e); + } +})(); + +// Export function for module import +export function toggleTheme() { + const html = document.documentElement; + const isDark = html.classList.toggle('dark'); + localStorage.setItem('theme', isDark ? 'dark' : 'light'); + html.style.colorScheme = isDark ? 'dark' : 'light'; +} diff --git a/stubs/forge-default/resources/views/auth/confirm-password.blade.php b/stubs/forge-default/resources/views/auth/confirm-password.blade.php new file mode 100644 index 000000000..5d5e1edff --- /dev/null +++ b/stubs/forge-default/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,27 @@ + +
+ {{ __('This is a secure area of the application. Please confirm your password before continuing.') }} +
+ +
+ @csrf + + +
+ + + + + +
+ +
+ + {{ __('Confirm') }} + +
+
+
diff --git a/stubs/forge-default/resources/views/auth/forgot-password.blade.php b/stubs/forge-default/resources/views/auth/forgot-password.blade.php new file mode 100644 index 000000000..3c707887b --- /dev/null +++ b/stubs/forge-default/resources/views/auth/forgot-password.blade.php @@ -0,0 +1,25 @@ + +
+ {{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }} +
+ + + + +
+ @csrf + + +
+ + + +
+ +
+ + {{ __('Email Password Reset Link') }} + +
+
+
diff --git a/stubs/forge-default/resources/views/auth/login.blade.php b/stubs/forge-default/resources/views/auth/login.blade.php new file mode 100644 index 000000000..711948fdf --- /dev/null +++ b/stubs/forge-default/resources/views/auth/login.blade.php @@ -0,0 +1,47 @@ + + + + +
+ @csrf + + +
+ + + +
+ + +
+ + + + + +
+ + +
+ +
+ +
+ @if (Route::has('password.request')) + + {{ __('Forgot your password?') }} + + @endif + + + {{ __('Log in') }} + +
+
+
diff --git a/stubs/forge-default/resources/views/auth/register.blade.php b/stubs/forge-default/resources/views/auth/register.blade.php new file mode 100644 index 000000000..e855a33ab --- /dev/null +++ b/stubs/forge-default/resources/views/auth/register.blade.php @@ -0,0 +1,52 @@ + +
+ @csrf + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ + +
+ + + + + +
+ +
+ + {{ __('Already registered?') }} + + + + {{ __('Register') }} + +
+
+
diff --git a/stubs/forge-default/resources/views/auth/reset-password.blade.php b/stubs/forge-default/resources/views/auth/reset-password.blade.php new file mode 100644 index 000000000..1dc3f76e4 --- /dev/null +++ b/stubs/forge-default/resources/views/auth/reset-password.blade.php @@ -0,0 +1,39 @@ + +
+ @csrf + + + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ +
+ + {{ __('Reset Password') }} + +
+
+
diff --git a/stubs/forge-default/resources/views/auth/verify-email.blade.php b/stubs/forge-default/resources/views/auth/verify-email.blade.php new file mode 100644 index 000000000..4e4222f4a --- /dev/null +++ b/stubs/forge-default/resources/views/auth/verify-email.blade.php @@ -0,0 +1,31 @@ + +
+ {{ __('Thanks for signing up! Before getting started, could you verify your email address by clicking on the link we just emailed to you? If you didn\'t receive the email, we will gladly send you another.') }} +
+ + @if (session('status') == 'verification-link-sent') +
+ {{ __('A new verification link has been sent to the email address you provided during registration.') }} +
+ @endif + +
+
+ @csrf + +
+ + {{ __('Resend Verification Email') }} + +
+
+ +
+ @csrf + + +
+
+
diff --git a/stubs/forge-default/resources/views/components/application-logo.blade.php b/stubs/forge-default/resources/views/components/application-logo.blade.php new file mode 100644 index 000000000..46579cf07 --- /dev/null +++ b/stubs/forge-default/resources/views/components/application-logo.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/stubs/forge-default/resources/views/components/auth-session-status.blade.php b/stubs/forge-default/resources/views/components/auth-session-status.blade.php new file mode 100644 index 000000000..a39bc7d2e --- /dev/null +++ b/stubs/forge-default/resources/views/components/auth-session-status.blade.php @@ -0,0 +1,7 @@ +@props(['status']) + +@if ($status) +
merge(['class' => 'font-medium text-sm text-green-600 dark:text-green-400']) }}> + {{ $status }} +
+@endif diff --git a/stubs/forge-default/resources/views/components/danger-button.blade.php b/stubs/forge-default/resources/views/components/danger-button.blade.php new file mode 100644 index 000000000..d7417b210 --- /dev/null +++ b/stubs/forge-default/resources/views/components/danger-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/stubs/forge-default/resources/views/components/dropdown-link.blade.php b/stubs/forge-default/resources/views/components/dropdown-link.blade.php new file mode 100644 index 000000000..6d5279d8b --- /dev/null +++ b/stubs/forge-default/resources/views/components/dropdown-link.blade.php @@ -0,0 +1 @@ +merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-800 transition duration-150 ease-in-out']) }}>{{ $slot }} diff --git a/stubs/forge-default/resources/views/components/dropdown.blade.php b/stubs/forge-default/resources/views/components/dropdown.blade.php new file mode 100644 index 000000000..e4106a4e9 --- /dev/null +++ b/stubs/forge-default/resources/views/components/dropdown.blade.php @@ -0,0 +1,35 @@ +@props(['align' => 'right', 'width' => '48', 'contentClasses' => 'py-1 bg-white dark:bg-gray-700']) + +@php +$alignmentClasses = match ($align) { + 'left' => 'ltr:origin-top-left rtl:origin-top-right start-0', + 'top' => 'origin-top', + default => 'ltr:origin-top-right rtl:origin-top-left end-0', +}; + +$width = match ($width) { + '48' => 'w-48', + default => $width, +}; +@endphp + +
+
+ {{ $trigger }} +
+ + +
diff --git a/stubs/forge-default/resources/views/components/input-error.blade.php b/stubs/forge-default/resources/views/components/input-error.blade.php new file mode 100644 index 000000000..ad95f6b57 --- /dev/null +++ b/stubs/forge-default/resources/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) + +@endif diff --git a/stubs/forge-default/resources/views/components/input-label.blade.php b/stubs/forge-default/resources/views/components/input-label.blade.php new file mode 100644 index 000000000..e93b059ac --- /dev/null +++ b/stubs/forge-default/resources/views/components/input-label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/stubs/forge-default/resources/views/components/modal.blade.php b/stubs/forge-default/resources/views/components/modal.blade.php new file mode 100644 index 000000000..e9fc7291c --- /dev/null +++ b/stubs/forge-default/resources/views/components/modal.blade.php @@ -0,0 +1,68 @@ +@props([ + 'name', + 'show' => false, + 'maxWidth' => '2xl', + 'focusable' => true +]) + +@php + $maxWidth = [ + 'sm' => 'sm:max-w-sm', + 'md' => 'sm:max-w-md', + 'lg' => 'sm:max-w-lg', + 'xl' => 'sm:max-w-xl', + '2xl' => 'sm:max-w-2xl', + ][$maxWidth ?? '2xl']; +@endphp + + diff --git a/stubs/forge-default/resources/views/components/nav-link.blade.php b/stubs/forge-default/resources/views/components/nav-link.blade.php new file mode 100644 index 000000000..37bad5542 --- /dev/null +++ b/stubs/forge-default/resources/views/components/nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' + : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/stubs/forge-default/resources/views/components/primary-button.blade.php b/stubs/forge-default/resources/views/components/primary-button.blade.php new file mode 100644 index 000000000..99bf38907 --- /dev/null +++ b/stubs/forge-default/resources/views/components/primary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/stubs/forge-default/resources/views/components/responsive-nav-link.blade.php b/stubs/forge-default/resources/views/components/responsive-nav-link.blade.php new file mode 100644 index 000000000..98b55d19e --- /dev/null +++ b/stubs/forge-default/resources/views/components/responsive-nav-link.blade.php @@ -0,0 +1,11 @@ +@props(['active']) + +@php +$classes = ($active ?? false) + ? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 dark:border-indigo-600 text-start text-base font-medium text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/50 focus:outline-none focus:text-indigo-800 dark:focus:text-indigo-200 focus:bg-indigo-100 dark:focus:bg-indigo-900 focus:border-indigo-700 dark:focus:border-indigo-300 transition duration-150 ease-in-out' + : 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 hover:border-gray-300 dark:hover:border-gray-600 focus:outline-none focus:text-gray-800 dark:focus:text-gray-200 focus:bg-gray-50 dark:focus:bg-gray-700 focus:border-gray-300 dark:focus:border-gray-600 transition duration-150 ease-in-out'; +@endphp + +merge(['class' => $classes]) }}> + {{ $slot }} + diff --git a/stubs/forge-default/resources/views/components/secondary-button.blade.php b/stubs/forge-default/resources/views/components/secondary-button.blade.php new file mode 100644 index 000000000..fa1c54918 --- /dev/null +++ b/stubs/forge-default/resources/views/components/secondary-button.blade.php @@ -0,0 +1,3 @@ + diff --git a/stubs/forge-default/resources/views/components/text-input.blade.php b/stubs/forge-default/resources/views/components/text-input.blade.php new file mode 100644 index 000000000..384b69d70 --- /dev/null +++ b/stubs/forge-default/resources/views/components/text-input.blade.php @@ -0,0 +1,3 @@ +@props(['disabled' => false]) + +merge(['class' => 'p-2 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm']) }}> diff --git a/stubs/forge-default/resources/views/dashboard.blade.php b/stubs/forge-default/resources/views/dashboard.blade.php new file mode 100644 index 000000000..4024c64a8 --- /dev/null +++ b/stubs/forge-default/resources/views/dashboard.blade.php @@ -0,0 +1,17 @@ + + +

+ {{ __('Dashboard') }} +

+
+ +
+
+
+
+ {{ __("You're logged in!") }} +
+
+
+
+
diff --git a/stubs/forge-default/resources/views/layouts/app.blade.php b/stubs/forge-default/resources/views/layouts/app.blade.php new file mode 100644 index 000000000..0a471a4d2 --- /dev/null +++ b/stubs/forge-default/resources/views/layouts/app.blade.php @@ -0,0 +1,36 @@ + + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+ @include('layouts.navigation') + + + @isset($header) +
+
+ {{ $header }} +
+
+ @endisset + + +
+ {{ $slot }} +
+
+ + diff --git a/stubs/forge-default/resources/views/layouts/guest.blade.php b/stubs/forge-default/resources/views/layouts/guest.blade.php new file mode 100644 index 000000000..4b369b639 --- /dev/null +++ b/stubs/forge-default/resources/views/layouts/guest.blade.php @@ -0,0 +1,30 @@ + + + + + + + + {{ config('app.name', 'Laravel') }} + + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + +
+
+ + + +
+ +
+ {{ $slot }} +
+
+ + diff --git a/stubs/forge-default/resources/views/layouts/navigation.blade.php b/stubs/forge-default/resources/views/layouts/navigation.blade.php new file mode 100644 index 000000000..c64bf6460 --- /dev/null +++ b/stubs/forge-default/resources/views/layouts/navigation.blade.php @@ -0,0 +1,100 @@ + diff --git a/stubs/forge-default/resources/views/profile/edit.blade.php b/stubs/forge-default/resources/views/profile/edit.blade.php new file mode 100644 index 000000000..ef699107f --- /dev/null +++ b/stubs/forge-default/resources/views/profile/edit.blade.php @@ -0,0 +1,29 @@ + + +

+ {{ __('Profile') }} +

+
+ +
+
+
+
+ @include('profile.partials.update-profile-information-form') +
+
+ +
+
+ @include('profile.partials.update-password-form') +
+
+ +
+
+ @include('profile.partials.delete-user-form') +
+
+
+
+
diff --git a/stubs/forge-default/resources/views/profile/partials/delete-user-form.blade.php b/stubs/forge-default/resources/views/profile/partials/delete-user-form.blade.php new file mode 100644 index 000000000..b3a638205 --- /dev/null +++ b/stubs/forge-default/resources/views/profile/partials/delete-user-form.blade.php @@ -0,0 +1,55 @@ +
+
+

+ {{ __('Delete Account') }} +

+ +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }} +

+
+ + {{ __('Delete Account') }} + + +
+ @csrf + @method('delete') + +

+ {{ __('Are you sure you want to delete your account?') }} +

+ +

+ {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} +

+ +
+ + + + + +
+ +
+ + {{ __('Cancel') }} + + + + {{ __('Delete Account') }} + +
+
+
+
diff --git a/stubs/forge-default/resources/views/profile/partials/update-password-form.blade.php b/stubs/forge-default/resources/views/profile/partials/update-password-form.blade.php new file mode 100644 index 000000000..0bdac156b --- /dev/null +++ b/stubs/forge-default/resources/views/profile/partials/update-password-form.blade.php @@ -0,0 +1,48 @@ +
+
+

+ {{ __('Update Password') }} +

+ +

+ {{ __('Ensure your account is using a long, random password to stay secure.') }} +

+
+ +
+ @csrf + @method('put') + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ {{ __('Save') }} + + @if (session('status') === 'password-updated') +

{{ __('Saved.') }}

+ @endif +
+
+
diff --git a/stubs/forge-default/resources/views/profile/partials/update-profile-information-form.blade.php b/stubs/forge-default/resources/views/profile/partials/update-profile-information-form.blade.php new file mode 100644 index 000000000..1ddab0ba5 --- /dev/null +++ b/stubs/forge-default/resources/views/profile/partials/update-profile-information-form.blade.php @@ -0,0 +1,64 @@ +
+
+

+ {{ __('Profile Information') }} +

+ +

+ {{ __("Update your account's profile information and email address.") }} +

+
+ +
+ @csrf +
+ +
+ @csrf + @method('put') + +
+ + + +
+ +
+ + + + + @if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail()) +
+

+ {{ __('Your email address is unverified.') }} + + +

+ + @if (session('status') === 'verification-link-sent') +

+ {{ __('A new verification link has been sent to your email address.') }} +

+ @endif +
+ @endif +
+ +
+ {{ __('Save') }} + + @if (session('status') === 'profile-updated') +

{{ __('Saved.') }}

+ @endif +
+
+
diff --git a/stubs/forge-default/routes/web.php.append b/stubs/forge-default/routes/web.php.append new file mode 100644 index 000000000..df0b1f9fc --- /dev/null +++ b/stubs/forge-default/routes/web.php.append @@ -0,0 +1,15 @@ +// Laravel Breeze added The following routes +// +use App\Http\Controllers\ProfileController; + +Route::get('/dashboard', function () { +return view('dashboard'); +})->middleware(['auth', 'verified'])->name('dashboard'); + +Route::middleware('auth')->group(function () { +Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); +Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); +Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); +}); + +// End routes added by Laravel Breeze diff --git a/stubs/forge-default/tailwind.config.js b/stubs/forge-default/tailwind.config.js new file mode 100644 index 000000000..945f9bdda --- /dev/null +++ b/stubs/forge-default/tailwind.config.js @@ -0,0 +1,12 @@ +// tailwind.config.js +export default { + darkMode: 'class', + content : [ + './index.html', + './src/**/*.{js,ts,jsx,tsx,vue,php,blade.php,html}' + ], + theme : { + extend: {}, + }, + plugins : [], +} diff --git a/stubs/forge-default/tests/Feature/Auth/AuthenticationTest.php b/stubs/forge-default/tests/Feature/Auth/AuthenticationTest.php new file mode 100644 index 000000000..13dcb7ce7 --- /dev/null +++ b/stubs/forge-default/tests/Feature/Auth/AuthenticationTest.php @@ -0,0 +1,54 @@ +get('/login'); + + $response->assertStatus(200); + } + + public function test_users_can_authenticate_using_the_login_screen(): void + { + $user = User::factory()->create(); + + $response = $this->post('/login', [ + 'email' => $user->email, + 'password' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } + + public function test_users_can_not_authenticate_with_invalid_password(): void + { + $user = User::factory()->create(); + + $this->post('/login', [ + 'email' => $user->email, + 'password' => 'wrong-password', + ]); + + $this->assertGuest(); + } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $this->assertGuest(); + $response->assertRedirect('/'); + } +} diff --git a/stubs/forge-default/tests/Feature/Auth/EmailVerificationTest.php b/stubs/forge-default/tests/Feature/Auth/EmailVerificationTest.php new file mode 100644 index 000000000..705570b43 --- /dev/null +++ b/stubs/forge-default/tests/Feature/Auth/EmailVerificationTest.php @@ -0,0 +1,58 @@ +unverified()->create(); + + $response = $this->actingAs($user)->get('/verify-email'); + + $response->assertStatus(200); + } + + public function test_email_can_be_verified(): void + { + $user = User::factory()->unverified()->create(); + + Event::fake(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1($user->email)] + ); + + $response = $this->actingAs($user)->get($verificationUrl); + + Event::assertDispatched(Verified::class); + $this->assertTrue($user->fresh()->hasVerifiedEmail()); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); + } + + public function test_email_is_not_verified_with_invalid_hash(): void + { + $user = User::factory()->unverified()->create(); + + $verificationUrl = URL::temporarySignedRoute( + 'verification.verify', + now()->addMinutes(60), + ['id' => $user->id, 'hash' => sha1('wrong-email')] + ); + + $this->actingAs($user)->get($verificationUrl); + + $this->assertFalse($user->fresh()->hasVerifiedEmail()); + } +} diff --git a/stubs/forge-default/tests/Feature/Auth/PasswordConfirmationTest.php b/stubs/forge-default/tests/Feature/Auth/PasswordConfirmationTest.php new file mode 100644 index 000000000..ff85721e2 --- /dev/null +++ b/stubs/forge-default/tests/Feature/Auth/PasswordConfirmationTest.php @@ -0,0 +1,44 @@ +create(); + + $response = $this->actingAs($user)->get('/confirm-password'); + + $response->assertStatus(200); + } + + public function test_password_can_be_confirmed(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'password', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + } + + public function test_password_is_not_confirmed_with_invalid_password(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/confirm-password', [ + 'password' => 'wrong-password', + ]); + + $response->assertSessionHasErrors(); + } +} diff --git a/stubs/forge-default/tests/Feature/Auth/PasswordResetTest.php b/stubs/forge-default/tests/Feature/Auth/PasswordResetTest.php new file mode 100644 index 000000000..aa5035058 --- /dev/null +++ b/stubs/forge-default/tests/Feature/Auth/PasswordResetTest.php @@ -0,0 +1,73 @@ +get('/forgot-password'); + + $response->assertStatus(200); + } + + public function test_reset_password_link_can_be_requested(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class); + } + + public function test_reset_password_screen_can_be_rendered(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) { + $response = $this->get('/reset-password/'.$notification->token); + + $response->assertStatus(200); + + return true; + }); + } + + public function test_password_can_be_reset_with_valid_token(): void + { + Notification::fake(); + + $user = User::factory()->create(); + + $this->post('/forgot-password', ['email' => $user->email]); + + Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) { + $response = $this->post('/reset-password', [ + 'token' => $notification->token, + 'email' => $user->email, + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('login')); + + return true; + }); + } +} diff --git a/stubs/forge-default/tests/Feature/Auth/PasswordUpdateTest.php b/stubs/forge-default/tests/Feature/Auth/PasswordUpdateTest.php new file mode 100644 index 000000000..ca28c6c6e --- /dev/null +++ b/stubs/forge-default/tests/Feature/Auth/PasswordUpdateTest.php @@ -0,0 +1,51 @@ +create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertTrue(Hash::check('new-password', $user->refresh()->password)); + } + + public function test_correct_password_must_be_provided_to_update_password(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->put('/password', [ + 'current_password' => 'wrong-password', + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]); + + $response + ->assertSessionHasErrorsIn('updatePassword', 'current_password') + ->assertRedirect('/profile'); + } +} diff --git a/stubs/forge-default/tests/Feature/Auth/RegistrationTest.php b/stubs/forge-default/tests/Feature/Auth/RegistrationTest.php new file mode 100644 index 000000000..1489d0e0f --- /dev/null +++ b/stubs/forge-default/tests/Feature/Auth/RegistrationTest.php @@ -0,0 +1,31 @@ +get('/register'); + + $response->assertStatus(200); + } + + public function test_new_users_can_register(): void + { + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(route('dashboard', absolute: false)); + } +} diff --git a/stubs/forge-default/tests/Feature/ProfileTest.php b/stubs/forge-default/tests/Feature/ProfileTest.php new file mode 100644 index 000000000..252fdcc52 --- /dev/null +++ b/stubs/forge-default/tests/Feature/ProfileTest.php @@ -0,0 +1,99 @@ +create(); + + $response = $this + ->actingAs($user) + ->get('/profile'); + + $response->assertOk(); + } + + public function test_profile_information_can_be_updated(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $user->refresh(); + + $this->assertSame('Test User', $user->name); + $this->assertSame('test@example.com', $user->email); + $this->assertNull($user->email_verified_at); + } + + public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->patch('/profile', [ + 'name' => 'Test User', + 'email' => $user->email, + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/profile'); + + $this->assertNotNull($user->refresh()->email_verified_at); + } + + public function test_user_can_delete_their_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->delete('/profile', [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect('/'); + + $this->assertGuest(); + $this->assertNull($user->fresh()); + } + + public function test_correct_password_must_be_provided_to_delete_account(): void + { + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->from('/profile') + ->delete('/profile', [ + 'password' => 'wrong-password', + ]); + + $response + ->assertSessionHasErrorsIn('userDeletion', 'password') + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()); + } +} diff --git a/stubs/forge-default/vite.config.js b/stubs/forge-default/vite.config.js new file mode 100644 index 000000000..8e4c669c4 --- /dev/null +++ b/stubs/forge-default/vite.config.js @@ -0,0 +1,13 @@ +import {defineConfig} from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input : ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], +});