From 0aae8b078c1217d4b5de7b16387ddac447ef1f26 Mon Sep 17 00:00:00 2001 From: Sebastiaan Kloos Date: Wed, 26 Nov 2025 14:07:48 +0100 Subject: [PATCH 1/3] feat(user): add email notification for new users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create UserCreated mailable that sends welcome emails to newly created users with their temporary password and login credentials. Email is queued for asynchronous processing and supports both English and Dutch translations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/Mail/UserCreated.php | 57 +++++++++++++++++++ lang/en/mail.php | 17 ++++++ lang/nl/mail.php | 17 ++++++ resources/views/emails/user-created.blade.php | 25 ++++++++ 4 files changed, 116 insertions(+) create mode 100644 app/Mail/UserCreated.php create mode 100644 lang/en/mail.php create mode 100644 lang/nl/mail.php create mode 100644 resources/views/emails/user-created.blade.php diff --git a/app/Mail/UserCreated.php b/app/Mail/UserCreated.php new file mode 100644 index 00000000..0ff0cfde --- /dev/null +++ b/app/Mail/UserCreated.php @@ -0,0 +1,57 @@ + config('app.name')]), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'emails.user-created', + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/lang/en/mail.php b/lang/en/mail.php new file mode 100644 index 00000000..46d3b9ff --- /dev/null +++ b/lang/en/mail.php @@ -0,0 +1,17 @@ + [ + 'subject' => 'Welcome to :app_name', + 'greeting' => 'Hello :name,', + 'intro' => 'Your account has been created! We\'re excited to have you on board.', + 'credentials-title' => 'Your login credentials', + 'credentials-intro' => 'You can use the following credentials to log in to your account:', + 'email-label' => 'Email address', + 'password-label' => 'Temporary password', + 'login-button' => 'Log in to your account', + 'security-notice' => 'For security reasons, we recommend changing your password after your first login.', + 'help-text' => 'If you have any questions or need assistance, feel free to reach out to our support team.', + 'closing' => 'Best regards', + ], +]; \ No newline at end of file diff --git a/lang/nl/mail.php b/lang/nl/mail.php new file mode 100644 index 00000000..172fe6c9 --- /dev/null +++ b/lang/nl/mail.php @@ -0,0 +1,17 @@ + [ + 'subject' => 'Welkom bij :app_name', + 'greeting' => 'Hallo :name,', + 'intro' => 'Je account is succesvol aangemaakt! We zijn blij dat je er bij bent.', + 'credentials-title' => 'Jouw inloggegevens', + 'credentials-intro' => 'Je kunt met de volgende gegevens inloggen op je account:', + 'email-label' => 'E-mailadres', + 'password-label' => 'Tijdelijk wachtwoord', + 'login-button' => 'Inloggen op je account', + 'security-notice' => 'Voor de veiligheid raden we aan om je wachtwoord te wijzigen na je eerste inlog.', + 'help-text' => 'Als je vragen hebt of hulp nodig hebt, neem gerust contact op met ons supportteam.', + 'closing' => 'Met vriendelijke groet', + ], +]; \ No newline at end of file diff --git a/resources/views/emails/user-created.blade.php b/resources/views/emails/user-created.blade.php new file mode 100644 index 00000000..f50892ee --- /dev/null +++ b/resources/views/emails/user-created.blade.php @@ -0,0 +1,25 @@ + +# {{ __('mail.user-created.greeting', ['name' => $user->name]) }} + +{{ __('mail.user-created.intro') }} + +## {{ __('mail.user-created.credentials-title') }} + +{{ __('mail.user-created.credentials-intro') }} + +**{{ __('mail.user-created.email-label') }}:** {{ $user->email }}
+**{{ __('mail.user-created.password-label') }}:** {{ $password }} + + +{{ __('mail.user-created.login-button') }} + + +--- + +{{ __('mail.user-created.security-notice') }} + +{{ __('mail.user-created.help-text') }} + +{{ __('mail.user-created.closing') }},
+{{ config('app.name') }} +
From bb2f22dfd2c71917ef89e1b30320071d4f12eead Mon Sep 17 00:00:00 2001 From: Sebastiaan Kloos Date: Wed, 26 Nov 2025 14:07:54 +0100 Subject: [PATCH 2/3] feat(user): implement automatic password generation and email sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add password generation functionality to CreateUser page that generates a secure 16-character password when creating new users. After creation, automatically sends welcome email with login credentials. Also add create action to ListUsers page header. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Resources/Users/Pages/CreateUser.php | 30 +++++++++++++++++++ .../Resources/Users/Pages/ListUsers.php | 8 +++++ 2 files changed, 38 insertions(+) diff --git a/app/Filament/Resources/Users/Pages/CreateUser.php b/app/Filament/Resources/Users/Pages/CreateUser.php index 125b3ff8..a67072f4 100644 --- a/app/Filament/Resources/Users/Pages/CreateUser.php +++ b/app/Filament/Resources/Users/Pages/CreateUser.php @@ -2,10 +2,40 @@ namespace App\Filament\Resources\Users\Pages; +use App\Mail\UserCreated; +use Illuminate\Support\Str; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Hash; use App\Filament\Resources\Users\UserResource; use Filament\Resources\Pages\CreateRecord; class CreateUser extends CreateRecord { protected static string $resource = UserResource::class; + + protected function mutateFormDataBeforeCreate(array $data): array + { + $plainPassword = Str::password(16, letters: true, numbers: true, symbols: true); + + $data['password'] = Hash::make($plainPassword); + + $this->plainPassword = $plainPassword; + + return $data; + } + + protected function afterCreate(): void + { + $loginUrl = route('login'); + + Mail::to($this->record->email)->send( + new UserCreated( + user: $this->record, + password: $this->plainPassword, + loginUrl: $loginUrl + ) + ); + } + + private string $plainPassword; } diff --git a/app/Filament/Resources/Users/Pages/ListUsers.php b/app/Filament/Resources/Users/Pages/ListUsers.php index ef196169..6f0e73de 100644 --- a/app/Filament/Resources/Users/Pages/ListUsers.php +++ b/app/Filament/Resources/Users/Pages/ListUsers.php @@ -3,9 +3,17 @@ namespace App\Filament\Resources\Users\Pages; use App\Filament\Resources\Users\UserResource; +use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; class ListUsers extends ListRecords { protected static string $resource = UserResource::class; + + protected function getHeaderActions(): array + { + return [ + CreateAction::make(), + ]; + } } From 8086fb3fc6047443c64d46b54749ac363eaa8656 Mon Sep 17 00:00:00 2001 From: Sebastiaan Kloos Date: Wed, 26 Nov 2025 14:08:00 +0100 Subject: [PATCH 3/3] test(user): add comprehensive tests for user creation workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add feature tests for user creation including permission checks, password generation validation, and email sending verification. Add unit tests for UserCreated mailable to verify proper configuration, queuing, and content rendering with translations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/Feature/Filament/CreateUserTest.php | 131 ++++++++++++++++++++++ tests/Unit/Mail/UserCreatedTest.php | 64 +++++++++++ 2 files changed, 195 insertions(+) create mode 100644 tests/Feature/Filament/CreateUserTest.php create mode 100644 tests/Unit/Mail/UserCreatedTest.php diff --git a/tests/Feature/Filament/CreateUserTest.php b/tests/Feature/Filament/CreateUserTest.php new file mode 100644 index 00000000..3307814b --- /dev/null +++ b/tests/Feature/Filament/CreateUserTest.php @@ -0,0 +1,131 @@ + UserRole::Admin]); + + Livewire::test(CreateUserPage::class) + ->assertSuccessful(); +}); + +test('regular user cannot access create user page', function () { + $user = createAndLoginUser(['role' => UserRole::User]); + + Livewire::test(CreateUserPage::class) + ->assertForbidden(); +}); + +test('creating a user generates a password and sends email', function () { + Mail::fake(); + $admin = createAndLoginUser(['role' => UserRole::Admin]); + + $userData = [ + 'name' => 'New Test User', + 'email' => 'newuser@example.com', + 'role' => UserRole::User->value, + ]; + + Livewire::test(CreateUserPage::class) + ->fillForm($userData) + ->call('create') + ->assertHasNoFormErrors(); + + $user = \App\Models\User::where('email', 'newuser@example.com')->first(); + + expect($user)->not->toBeNull() + ->and($user->name)->toBe('New Test User') + ->and($user->email)->toBe('newuser@example.com') + ->and($user->role)->toBe(UserRole::User) + ->and($user->password)->not->toBeNull(); + + Mail::assertQueued(UserCreated::class, function ($mail) use ($user) { + return $mail->hasTo($user->email) + && $mail->user->id === $user->id + && !empty($mail->password) + && $mail->loginUrl === route('login'); + }); +}); + +test('created user password is properly hashed', function () { + Mail::fake(); + $admin = createAndLoginUser(['role' => UserRole::Admin]); + + $userData = [ + 'name' => 'Test User With Password', + 'email' => 'passwordtest@example.com', + 'role' => UserRole::Employee->value, + ]; + + Livewire::test(CreateUserPage::class) + ->fillForm($userData) + ->call('create') + ->assertHasNoFormErrors(); + + $user = \App\Models\User::where('email', 'passwordtest@example.com')->first(); + + expect($user->password)->not->toBeNull() + ->and(Hash::needsRehash($user->password))->toBeFalse(); +}); + +test('email contains the plain password not the hashed one', function () { + Mail::fake(); + $admin = createAndLoginUser(['role' => UserRole::Admin]); + + $userData = [ + 'name' => 'Another User', + 'email' => 'another@example.com', + 'role' => UserRole::Admin->value, + ]; + + Livewire::test(CreateUserPage::class) + ->fillForm($userData) + ->call('create') + ->assertHasNoFormErrors(); + + $user = \App\Models\User::where('email', 'another@example.com')->first(); + + Mail::assertQueued(UserCreated::class, function ($mail) use ($user) { + return $mail->user->id === $user->id + && $mail->password !== $user->password + && strlen($mail->password) === 16; + }); +}); + +test('employee cannot access create user page', function () { + $employee = createAndLoginUser(['role' => UserRole::Employee]); + + Livewire::test(CreateUserPage::class) + ->assertForbidden(); +}); + +test('created user receives email with correct translations', function () { + Mail::fake(); + $admin = createAndLoginUser(['role' => UserRole::Admin]); + + $userData = [ + 'name' => 'Translation Test User', + 'email' => 'translation@example.com', + 'role' => UserRole::User->value, + ]; + + Livewire::test(CreateUserPage::class) + ->fillForm($userData) + ->call('create') + ->assertHasNoFormErrors(); + + $user = \App\Models\User::where('email', 'translation@example.com')->first(); + + Mail::assertQueued(UserCreated::class, function ($mail) use ($user) { + $mailable = new UserCreated($mail->user, $mail->password, $mail->loginUrl); + $rendered = $mailable->render(); + + return str_contains($rendered, trans('mail.user-created.greeting', ['name' => $user->name])) + && str_contains($rendered, trans('mail.user-created.login-button')); + }); +}); diff --git a/tests/Unit/Mail/UserCreatedTest.php b/tests/Unit/Mail/UserCreatedTest.php new file mode 100644 index 00000000..dc333316 --- /dev/null +++ b/tests/Unit/Mail/UserCreatedTest.php @@ -0,0 +1,64 @@ + 'Test User', 'email' => 'test@example.com']); + $password = 'TestPassword123!'; + $loginUrl = route('login'); + + $mailable = new UserCreated($user, $password, $loginUrl); + + expect($mailable->user)->toBe($user) + ->and($mailable->password)->toBe($password) + ->and($mailable->loginUrl)->toBe($loginUrl); +}); + +it('has the correct subject with app name', function () { + $user = createUser(['name' => 'Test User']); + $password = 'TestPassword123!'; + $loginUrl = route('login'); + + $mailable = new UserCreated($user, $password, $loginUrl); + $envelope = $mailable->envelope(); + + expect($envelope->subject)->toBe(trans('mail.user-created.subject', ['app_name' => config('app.name')])); +}); + +it('uses the correct markdown view', function () { + $user = createUser(['name' => 'Test User']); + $password = 'TestPassword123!'; + $loginUrl = route('login'); + + $mailable = new UserCreated($user, $password, $loginUrl); + $content = $mailable->content(); + + expect($content->markdown)->toBe('emails.user-created'); +}); + +it('should be queued', function () { + $user = createUser(['name' => 'Test User']); + $password = 'TestPassword123!'; + $loginUrl = route('login'); + + $mailable = new UserCreated($user, $password, $loginUrl); + + expect($mailable)->toBeInstanceOf(\Illuminate\Contracts\Queue\ShouldQueue::class); +}); + +it('renders the email with correct content', function () { + $user = createUser(['name' => 'Test User', 'email' => 'test@example.com']); + $password = 'TestPassword123!'; + $loginUrl = route('login'); + + $mailable = new UserCreated($user, $password, $loginUrl); + $rendered = $mailable->render(); + + expect($rendered) + ->toContain($user->name) + ->toContain($user->email) + ->toContain($password) + ->toContain($loginUrl); +});