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(), + ]; + } } 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') }} +
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); +});