From 04a9d2512bf68fa40b8f272d6fc80009a31f1a0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9ia=20Bohner?= Date: Mon, 6 Jul 2026 20:43:46 -0300 Subject: [PATCH 1/2] Add invite bulk action --- README.md | 17 ++++++ resources/lang/en/invite.php | 19 +++++- src/Actions/InviteAction.php | 17 ++---- src/Actions/InviteBulkAction.php | 84 +++++++++++++++++++++++++++ src/Concerns/InvitesUsers.php | 41 +++++++++++++ src/FilamentInviteServiceProvider.php | 6 +- tests/InviteBulkActionTest.php | 63 ++++++++++++++++++++ 7 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 src/Actions/InviteBulkAction.php create mode 100644 src/Concerns/InvitesUsers.php create mode 100644 tests/InviteBulkActionTest.php diff --git a/README.md b/README.md index f8a8e82..1e876d1 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,23 @@ Add invite action to header actions in your resource view or edit page (e.g. `Ap ``` +Add invite bulk action to table toolbar actions + +```php +use Filament\Actions\BulkActionGroup; +use Tapp\FilamentInvite\Actions\InviteBulkAction; + +public static function table(Table $table): Table +{ + return $table + ->toolbarActions([ + BulkActionGroup::make([ + InviteBulkAction::make(), + ]), + ]); +} +``` + ## Customization ### Reset URL diff --git a/resources/lang/en/invite.php b/resources/lang/en/invite.php index 8c723e8..9971269 100644 --- a/resources/lang/en/invite.php +++ b/resources/lang/en/invite.php @@ -1,6 +1,21 @@ [ + 'label' => 'Invite', + 'modal_heading' => 'Send Invite Emails', + 'notifications' => [ + 'none' => [ + 'title' => 'No invites sent', + 'body' => 'Selected users are already verified or cannot be invited.', + ], + 'sent' => [ + 'title' => 'Invites sent', + 'body' => '{1} :count invite sent.|[2,*] :count invites sent.', + ], + 'skipped' => [ + 'body' => '{1} :count user skipped.|[2,*] :count users skipped.', + ], + ], + ], ]; diff --git a/src/Actions/InviteAction.php b/src/Actions/InviteAction.php index 007be86..baa8267 100644 --- a/src/Actions/InviteAction.php +++ b/src/Actions/InviteAction.php @@ -4,16 +4,14 @@ use Filament\Actions\Action; use Filament\Actions\Concerns\CanCustomizeProcess; -use Filament\Facades\Filament; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Notification; -use Illuminate\Support\Facades\Password; -use Tapp\FilamentInvite\Notifications\SetPassword; +use Tapp\FilamentInvite\Concerns\InvitesUsers; class InviteAction extends Action { use CanCustomizeProcess; + use InvitesUsers; public static function getDefaultName(): ?string { @@ -38,15 +36,8 @@ protected function setUp(): void }); $this->action(function (): void { - $result = $this->process(static function (Model $user) { - $token = Password::broker(Filament::getAuthPasswordBroker())->createToken($user); - - // Use the method if the developer has specified one - if (method_exists($user, 'sendPasswordSetNotification')) { - $user->sendPasswordSetNotification($token); - } else { - Notification::send($user, new SetPassword($token)); - } + $this->process(function (Model $user): void { + $this->sendInviteToUser($user); }); $this->success(); diff --git a/src/Actions/InviteBulkAction.php b/src/Actions/InviteBulkAction.php new file mode 100644 index 0000000..b7e0bc1 --- /dev/null +++ b/src/Actions/InviteBulkAction.php @@ -0,0 +1,84 @@ +label(__('filament-invite::invite.bulk.label')); + + $this->icon('heroicon-m-envelope'); + + $this->requiresConfirmation(); + + $this->modalHeading(__('filament-invite::invite.bulk.modal_heading')); + + $this->action(function (Collection $records): void { + $invitedCount = 0; + $skippedCount = 0; + + foreach ($records as $user) { + if (! $user instanceof Model) { + continue; + } + + if (! $this->canInviteUser($user)) { + $skippedCount++; + + continue; + } + + $this->sendInviteToUser($user); + $invitedCount++; + } + + if ($invitedCount === 0) { + Notification::make() + ->title(__('filament-invite::invite.bulk.notifications.none.title')) + ->body(__('filament-invite::invite.bulk.notifications.none.body')) + ->warning() + ->send(); + + return; + } + + $body = trans_choice( + 'filament-invite::invite.bulk.notifications.sent.body', + $invitedCount, + ['count' => $invitedCount], + ); + + if ($skippedCount > 0) { + $body .= ' ' . trans_choice( + 'filament-invite::invite.bulk.notifications.skipped.body', + $skippedCount, + ['count' => $skippedCount], + ); + } + + Notification::make() + ->title(__('filament-invite::invite.bulk.notifications.sent.title')) + ->body($body) + ->success() + ->send(); + }); + + $this->deselectRecordsAfterCompletion(); + } +} diff --git a/src/Concerns/InvitesUsers.php b/src/Concerns/InvitesUsers.php new file mode 100644 index 0000000..549346f --- /dev/null +++ b/src/Concerns/InvitesUsers.php @@ -0,0 +1,41 @@ +createToken($user); + + if (method_exists($user, 'sendPasswordSetNotification')) { + $user->sendPasswordSetNotification($token); + + return; + } + + Notification::send($user, new SetPassword($token)); + } + + protected function canInviteUser(Model $user): bool + { + if ($user instanceof MustVerifyEmail && $user->hasVerifiedEmail()) { + return false; + } + + $authenticatedUser = auth()->user(); + + if ($authenticatedUser === null) { + return false; + } + + return $authenticatedUser->can('update', $user); + } +} diff --git a/src/FilamentInviteServiceProvider.php b/src/FilamentInviteServiceProvider.php index 3ab7d43..8aaf121 100644 --- a/src/FilamentInviteServiceProvider.php +++ b/src/FilamentInviteServiceProvider.php @@ -28,9 +28,9 @@ public function configurePackage(Package $package): void ->hasInstallCommand(function (InstallCommand $command) { $command ->publishConfigFile(); - }); - - $package->hasConfigFile(); + }) + ->hasConfigFile() + ->hasTranslations(); } public function packageBooted(): void diff --git a/tests/InviteBulkActionTest.php b/tests/InviteBulkActionTest.php new file mode 100644 index 0000000..c49a57b --- /dev/null +++ b/tests/InviteBulkActionTest.php @@ -0,0 +1,63 @@ +toBe('invite_users'); +}); + +it('can determine which users are eligible for invites', function (): void { + $inviter = new class extends Authenticatable + { + use Notifiable; + + public function can($abilities, $arguments = []): bool + { + return $abilities === 'update'; + } + }; + + $unverifiedUser = new class extends Authenticatable implements MustVerifyEmail + { + use Notifiable; + + protected $table = 'users'; + + public function hasVerifiedEmail(): bool + { + return false; + } + }; + + $verifiedUser = new class extends Authenticatable implements MustVerifyEmail + { + use Notifiable; + + protected $table = 'users'; + + public function hasVerifiedEmail(): bool + { + return true; + } + }; + + $action = new class extends Model + { + use InvitesUsers; + + public function exposeCanInviteUser(Model $user): bool + { + return $this->canInviteUser($user); + } + }; + + $this->actingAs($inviter); + + expect($action->exposeCanInviteUser($unverifiedUser))->toBeTrue() + ->and($action->exposeCanInviteUser($verifiedUser))->toBeFalse(); +}); From b8baf7190909a4532f37292fb06301fec9e58c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9ia=20Bohner?= Date: Mon, 6 Jul 2026 20:49:06 -0300 Subject: [PATCH 2/2] Updates --- phpstan-baseline.neon | 13 +------------ src/Actions/InviteAction.php | 5 ++++- src/Actions/InviteBulkAction.php | 5 ----- src/Concerns/InvitesUsers.php | 10 +++++++++- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3e9f010..aab4991 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,13 +1,2 @@ parameters: - ignoreErrors: - - - message: '#^Call to an undefined method Illuminate\\Contracts\\Auth\\PasswordBroker\:\:createToken\(\)\.$#' - identifier: method.notFound - count: 1 - path: src/Actions/InviteAction.php - - - - message: '#^PHPDoc tag @var with type Illuminate\\Contracts\\Auth\\MustVerifyEmail is not subtype of native type Illuminate\\Database\\Eloquent\\Model\.$#' - identifier: varTag.nativeType - count: 1 - path: src/Actions/InviteAction.php + ignoreErrors: [] diff --git a/src/Actions/InviteAction.php b/src/Actions/InviteAction.php index baa8267..49bfaac 100644 --- a/src/Actions/InviteAction.php +++ b/src/Actions/InviteAction.php @@ -31,7 +31,10 @@ protected function setUp(): void $this->icon('heroicon-m-envelope'); $this->hidden(function (Model $user) { - /** @var MustVerifyEmail $user */ + if (! $user instanceof MustVerifyEmail) { + return true; + } + return $user->hasVerifiedEmail() || auth()->user()->can('update', $user) === false; }); diff --git a/src/Actions/InviteBulkAction.php b/src/Actions/InviteBulkAction.php index b7e0bc1..13b0af3 100644 --- a/src/Actions/InviteBulkAction.php +++ b/src/Actions/InviteBulkAction.php @@ -5,7 +5,6 @@ use Filament\Actions\BulkAction; use Filament\Notifications\Notification; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Model; use Tapp\FilamentInvite\Concerns\InvitesUsers; class InviteBulkAction extends BulkAction @@ -34,10 +33,6 @@ protected function setUp(): void $skippedCount = 0; foreach ($records as $user) { - if (! $user instanceof Model) { - continue; - } - if (! $this->canInviteUser($user)) { $skippedCount++; diff --git a/src/Concerns/InvitesUsers.php b/src/Concerns/InvitesUsers.php index 549346f..69e164b 100644 --- a/src/Concerns/InvitesUsers.php +++ b/src/Concerns/InvitesUsers.php @@ -3,17 +3,25 @@ namespace Tapp\FilamentInvite\Concerns; use Filament\Facades\Filament; +use Illuminate\Auth\Passwords\PasswordBroker; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Password; +use RuntimeException; use Tapp\FilamentInvite\Notifications\SetPassword; trait InvitesUsers { protected function sendInviteToUser(Model $user): void { - $token = Password::broker(Filament::getAuthPasswordBroker())->createToken($user); + $broker = Password::broker(Filament::getAuthPasswordBroker()); + + if (! $broker instanceof PasswordBroker) { + throw new RuntimeException('Unexpected password broker implementation.'); + } + + $token = $broker->createToken($user); if (method_exists($user, 'sendPasswordSetNotification')) { $user->sendPasswordSetNotification($token);