diff --git a/composer.json b/composer.json index ac5dc42e..cc413acb 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ "spatie/laravel-permission": "^6.3", "laravel/framework": "^10.0", "rawilk/filament-password-input": "^2.0", - "ralphjsmit/laravel-filament-seo": "^1.3" + "ralphjsmit/laravel-filament-seo": "^1.3", + "laravel/fortify": "^1.21" }, "require-dev": { "laravel/sanctum": "^3.3", diff --git a/composer.lock b/composer.lock index d3bdde94..f6e6c96c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "56cfa7f5644501e9357d7f7c9978c15f", + "content-hash": "b7b056291e8854fafa84605a4c6683a6", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -155,6 +155,60 @@ ], "time": "2024-02-28T21:33:43+00:00" }, + { + "name": "bacon/bacon-qr-code", + "version": "2.0.8", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", + "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.1", + "phpunit/phpunit": "^7 | ^8 | ^9", + "spatie/phpunit-snapshot-assertions": "^4.2.9", + "squizlabs/php_codesniffer": "^3.4" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + }, + "time": "2022-12-07T17:46:57+00:00" + }, { "name": "blade-ui-kit/blade-heroicons", "version": "2.3.0", @@ -534,6 +588,56 @@ ], "time": "2024-01-21T14:53:34+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/6faf451159fb8ba4126b925ed2d78acfce0dc016", + "reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 | ^8 | ^9", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.5" + }, + "time": "2023-08-25T16:18:39+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.2", @@ -2332,6 +2436,71 @@ }, "time": "2024-02-13T15:40:14+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.21.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "405388fd399264715573e23ed2f368fbce426da3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/405388fd399264715573e23ed2f368fbce426da3", + "reference": "405388fd399264715573e23ed2f368fbce426da3", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^2.0", + "ext-json": "*", + "illuminate/support": "^10.0|^11.0", + "php": "^8.1", + "pragmarx/google2fa": "^8.0", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^8.16|^9.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + }, + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2024-03-19T20:08:25+00:00" + }, { "name": "laravel/framework", "version": "v10.47.0", @@ -4201,6 +4370,73 @@ ], "time": "2024-01-09T09:30:37+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v2.6.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "58c3f47f650c94ec05a151692652a868995d2938" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/58c3f47f650c94ec05a151692652a868995d2938", + "reference": "58c3f47f650c94ec05a151692652a868995d2938", + "shasum": "" + }, + "require": { + "php": "^7|^8" + }, + "require-dev": { + "phpunit/phpunit": "^6|^7|^8|^9", + "vimeo/psalm": "^1|^2|^3|^4" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2022-06-14T06:56:20+00:00" + }, { "name": "parsecsv/php-parsecsv", "version": "1.3.2", @@ -4340,6 +4576,58 @@ ], "time": "2023-11-12T21:59:55+00:00" }, + { + "name": "pragmarx/google2fa", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "80c3d801b31fe165f8fe99ea085e0a37834e1be3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/80c3d801b31fe165f8fe99ea085e0a37834e1be3", + "reference": "80c3d801b31fe165f8fe99ea085e0a37834e1be3", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.18", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v8.0.1" + }, + "time": "2022-06-13T21:57:56+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -13156,5 +13444,5 @@ "prefer-lowest": false, "platform": [], "platform-dev": [], - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.3.0" } diff --git a/database/migrations/2024_04_12_000000_create_user_logins_table.php b/database/migrations/2024_04_12_000000_create_user_logins_table.php new file mode 100644 index 00000000..c842e647 --- /dev/null +++ b/database/migrations/2024_04_12_000000_create_user_logins_table.php @@ -0,0 +1,32 @@ +id(); + /** + * We are not sure if they may use UUID or auto-incrementing values + * We also cannot guarantee that the auth model will be called "User" + * so we cannot put a foreign key to it + */ + $table->string('user_id'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_logins'); + } +}; diff --git a/src/Actions/Fortify/ResetUserPassword.php b/src/Actions/Fortify/ResetUserPassword.php new file mode 100644 index 00000000..96330303 --- /dev/null +++ b/src/Actions/Fortify/ResetUserPassword.php @@ -0,0 +1,28 @@ + 'required|string|confirmed|min:16', + ])->validate(); + + $user->forceFill([ + 'password' => Hash::make($input['password']), + ])->save(); + } +} diff --git a/src/Commands/AddUserConcerns.php b/src/Commands/AddUserConcerns.php index 4ae7c3b6..ed159b30 100644 --- a/src/Commands/AddUserConcerns.php +++ b/src/Commands/AddUserConcerns.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\File; use ReflectionClass; use Spatie\Permission\Traits\HasRoles; +use Portable\FilaCms\Contracts\HasLogin; class AddUserConcerns extends Command { @@ -24,6 +25,7 @@ public function handle() $traits = [ HasRoles::class, + HasLogin::class, ]; $interfaces = [ diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index f431c1a4..c7ffe566 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -21,6 +21,8 @@ public function handle() $this->info('Installed Filament Base. Installing Spatie Permissions'); + $this->call('fortify:install'); + $this->call('vendor:publish', ['--provider' => "Spatie\Permission\PermissionServiceProvider"]); $this->call('vendor:publish', ['--provider' => "Venturecraft\Revisionable\RevisionableServiceProvider"]); $this->call('vendor:publish', ['--tag' => "seo-migrations"]); diff --git a/src/Contracts/HasLogin.php b/src/Contracts/HasLogin.php new file mode 100644 index 00000000..dbd5c74f --- /dev/null +++ b/src/Contracts/HasLogin.php @@ -0,0 +1,13 @@ +hasMany(UserLogin::class, 'user_id'); + } +} diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index b0d8db79..2fcea926 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -4,9 +4,14 @@ use Filament\Forms; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Tables; +use Filament\Tables\Actions\Action; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Model; +use Password as PasswordReset; use Portable\FilaCms\Filament\Resources\UserResource\Pages; +use Portable\FilaCms\Filament\Resources\UserResource\RelationManagers; use Portable\FilaCms\Filament\Traits\IsProtectedResource; use Rawilk\FilamentPasswordInput\Password; @@ -31,12 +36,12 @@ public static function form(Form $form): Form Forms\Components\TextInput::make('email') ->email() ->prefixIcon('heroicon-m-envelope') + ->unique(ignoreRecord:true) ->required(), Password::make('password') ->regeneratePassword(color: 'warning') ->copyable(color: 'info') - ->newPasswordLength(16) - ->required(), + ->newPasswordLength(16), Forms\Components\Select::make('roles') ->relationship('roles', 'name') ->multiple() @@ -44,9 +49,15 @@ public static function form(Form $form): Form ]); } + public static function getModel(): string + { + return config('auth.providers.users.model'); + } + public static function table(Table $table): Table { static::$model = config('auth.providers.users.model'); + return $table ->columns(static::getTableColumns()) ->filters([ @@ -54,6 +65,17 @@ public static function table(Table $table): Table ]) ->actions([ Tables\Actions\EditAction::make(), + Action::make('send_reset_link') + ->label('Send Password Reset') + ->icon('heroicon-s-inbox') + ->action(function (Model $user) { + PasswordReset::broker()->sendResetLink(['email' => $user->email]); + Notification::make() + ->title('Reset Link Sent') + ->body('Password reset link has been sent to ' . $user->email) + ->success() + ->send(); + }) ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ @@ -65,7 +87,7 @@ public static function table(Table $table): Table public static function getRelations(): array { return [ - // + RelationManagers\UserLoginsRelationManager::class, ]; } diff --git a/src/Filament/Resources/UserResource/Pages/EditUser.php b/src/Filament/Resources/UserResource/Pages/EditUser.php index b294081e..986d7f9e 100644 --- a/src/Filament/Resources/UserResource/Pages/EditUser.php +++ b/src/Filament/Resources/UserResource/Pages/EditUser.php @@ -16,4 +16,13 @@ protected function getHeaderActions(): array Actions\DeleteAction::make(), ]; } + + public function mutateFormDataBeforeSave(array $data): array + { + if((isset($data['password']) && ($data['password'] === '')) || is_null($data['password'])) { + unset($data['password']); + } + + return $data; + } } diff --git a/src/Filament/Resources/UserResource/RelationManagers/UserLoginsRelationManager.php b/src/Filament/Resources/UserResource/RelationManagers/UserLoginsRelationManager.php new file mode 100644 index 00000000..b9272d56 --- /dev/null +++ b/src/Filament/Resources/UserResource/RelationManagers/UserLoginsRelationManager.php @@ -0,0 +1,35 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('created_at') + ->columns([ + Tables\Columns\ViewColumn::make('created_at') + ->label('Login Time') + ->view('fila-cms::tables.columns.created_at'), + ]); + } +} diff --git a/src/Listeners/AuthenticationListener.php b/src/Listeners/AuthenticationListener.php new file mode 100644 index 00000000..6e2415e0 --- /dev/null +++ b/src/Listeners/AuthenticationListener.php @@ -0,0 +1,16 @@ + $event->user->id, + ]); + } +} diff --git a/src/Models/UserLogin.php b/src/Models/UserLogin.php new file mode 100644 index 00000000..c3a64240 --- /dev/null +++ b/src/Models/UserLogin.php @@ -0,0 +1,19 @@ +belongsTo($userModelClass, 'user_id'); + } +} diff --git a/src/Providers/FilaCmsServiceProvider.php b/src/Providers/FilaCmsServiceProvider.php index da3b2034..969c7684 100644 --- a/src/Providers/FilaCmsServiceProvider.php +++ b/src/Providers/FilaCmsServiceProvider.php @@ -2,14 +2,19 @@ namespace Portable\FilaCms\Providers; +use FilamentTiptapEditor\TiptapEditor; +use Illuminate\Auth\Events\Login; use Illuminate\Foundation\AliasLoader; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use Laravel\Fortify\Fortify; use Livewire\Livewire; +use Portable\FilaCms\Actions\Fortify\ResetUserPassword; use Portable\FilaCms\Facades\FilaCms as FacadesFilaCms; use Portable\FilaCms\FilaCms; -use FilamentTiptapEditor\TiptapEditor; use Portable\FilaCms\Filament\Blocks\RelatedResourceBlock; +use Portable\FilaCms\Listeners\AuthenticationListener; class FilaCmsServiceProvider extends ServiceProvider { @@ -42,6 +47,18 @@ public function boot() Livewire::component('portable.fila-cms.livewire.content-resource-list', \Portable\FilaCms\Livewire\ContentResourceList::class); Livewire::component('portable.fila-cms.livewire.content-resource-show', \Portable\FilaCms\Livewire\ContentResourceShow::class); Blade::componentNamespace('Portable\\FilaCms\\Views\\Components', 'fila-cms'); + + Event::listen(Login::class, AuthenticationListener::class); + + Fortify::resetPasswordView(function () { + return view('fila-cms::auth.reset-password'); + }); + + Fortify::loginView(function () { + return redirect(route('filament.admin.auth.login')); + }); + + Fortify::resetUserPasswordsUsing(ResetUserPassword::class); } public function register() diff --git a/testbench.yaml b/testbench.yaml index c8998749..65e7ba04 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -14,6 +14,7 @@ providers: - Spatie\Permission\PermissionServiceProvider - Laravel\Sanctum\SanctumServiceProvider - Portable\FilaCms\Tests\Providers\WorkbenchServiceProvider + - Laravel\Fortify\FortifyServiceProvider migrations: - workbench/database/migrations diff --git a/tests/Listeners/ServeCommandStartedListener.php b/tests/Listeners/ServeCommandStartedListener.php index ea97c555..2f03b58e 100644 --- a/tests/Listeners/ServeCommandStartedListener.php +++ b/tests/Listeners/ServeCommandStartedListener.php @@ -2,7 +2,6 @@ namespace Portable\FilaCms\Tests\Listeners; -use Illuminate\Foundation\Auth\User; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Hash; @@ -11,6 +10,10 @@ class ServeCommandStartedListener { public function handle(): void { + // Create the user model stub + Artisan::call('make:user-model'); + config(['auth.providers.users.model', 'Workbench\App\Models\User']); + Artisan::call('package:create-sqlite-db'); Artisan::call('migrate:fresh'); @@ -26,7 +29,7 @@ public function handle(): void File::delete(resource_path('css/filament/admin/tailwind.config.js')); File::delete(resource_path('css/filament/admin/theme.css')); - Artisan::call('fila-cms:install', ['--publish-config' => true,'--run-migrations' => true,'--add-user-traits' => true]); + Artisan::call('fila-cms:install', ['--publish-config' => true,'--run-migrations' => true,'--add-user-traits' => false]); // Ensure there's an admin user $userModel = config('auth.providers.users.model'); diff --git a/tests/Providers/WorkbenchServiceProvider.php b/tests/Providers/WorkbenchServiceProvider.php index 82d68542..056697c5 100644 --- a/tests/Providers/WorkbenchServiceProvider.php +++ b/tests/Providers/WorkbenchServiceProvider.php @@ -23,6 +23,9 @@ class WorkbenchServiceProvider extends EventServiceProvider public function register(): void { parent::register(); + if(class_exists('Workbench\App\Models\User')) { + config(['auth.providers.users.model' => 'Workbench\App\Models\User']); + } } /** diff --git a/tests/User.php b/tests/User.php index 7c903500..66c613b0 100644 --- a/tests/User.php +++ b/tests/User.php @@ -8,6 +8,7 @@ class User extends Authenticatable implements \Filament\Models\Contracts\FilamentUser { + use \Portable\FilaCms\Contracts\HasLogin; use \Spatie\Permission\Traits\HasRoles; use HasFactory; diff --git a/views/auth/reset-password.blade.php b/views/auth/reset-password.blade.php new file mode 100644 index 00000000..2d9b760a --- /dev/null +++ b/views/auth/reset-password.blade.php @@ -0,0 +1,45 @@ + + +
+
+ @csrf + + + + + +
+ + + + +
+ + +
+ + + +
+ + +
+ + + + + +
+ +
+ + {{ __('Reset Password') }} + +
+
+
+ +
diff --git a/views/components/input-error.blade.php b/views/components/input-error.blade.php new file mode 100644 index 00000000..907c4c10 --- /dev/null +++ b/views/components/input-error.blade.php @@ -0,0 +1,9 @@ +@props(['messages']) + +@if ($messages) + +@endif diff --git a/views/components/input-label.blade.php b/views/components/input-label.blade.php new file mode 100644 index 00000000..ddf819bc --- /dev/null +++ b/views/components/input-label.blade.php @@ -0,0 +1,5 @@ +@props(['value']) + + diff --git a/views/components/primary-button.blade.php b/views/components/primary-button.blade.php new file mode 100644 index 00000000..12f7817c --- /dev/null +++ b/views/components/primary-button.blade.php @@ -0,0 +1,4 @@ + diff --git a/views/components/text-input.blade.php b/views/components/text-input.blade.php new file mode 100644 index 00000000..92804a5f --- /dev/null +++ b/views/components/text-input.blade.php @@ -0,0 +1,6 @@ +@props(['disabled' => false]) + +merge([ + 'class' => + '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/workbench/app/Models/User.php b/workbench/app/Models/User.php new file mode 100644 index 00000000..762dd812 --- /dev/null +++ b/workbench/app/Models/User.php @@ -0,0 +1,63 @@ + + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var array + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + + + public function canAccessFilament(): bool + { + // This is required on Front and Back end. Add more specific controls with authenticate middleware. + return true; + } + + + + public function canAccessPanel($panel): bool + { + // This is required on Front and Back end. Add more specific controls with authenticate middleware. + return true; + } + +}