diff --git a/app/Livewire/Auth/ForgotPassword.php b/app/Livewire/Auth/ForgotPassword.php index 20f9fca..6fd8dea 100644 --- a/app/Livewire/Auth/ForgotPassword.php +++ b/app/Livewire/Auth/ForgotPassword.php @@ -29,6 +29,20 @@ class ForgotPassword extends Component return ! in_array(config('mail.default'), ['log', 'array', null], true); } + /** Secondary path: email a reset link (only meaningful when a real mailer is set). */ + public function sendResetLink(): void + { + if (! $this->mailEnabled()) { + return; + } + + $this->validate(['email' => ['required', 'email']]); + \Illuminate\Support\Facades\Password::sendResetLink(['email' => $this->email]); + + // Generic — never reveal whether the email exists. + $this->dispatch('notify', message: __('auth.reset_link_sent')); + } + /** Reset the password by proving 2FA possession (TOTP or a one-time backup code). */ public function resetPassword() { diff --git a/app/Livewire/Auth/ResetPassword.php b/app/Livewire/Auth/ResetPassword.php new file mode 100644 index 0000000..6718ba0 --- /dev/null +++ b/app/Livewire/Auth/ResetPassword.php @@ -0,0 +1,70 @@ +token = $token; + $this->email = (string) request()->query('email', ''); + } + + /** Consume the emailed reset token and set a new password. */ + public function resetPassword() + { + $this->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::min(12)->mixedCase()->numbers()], + ]); + + $status = Password::reset( + [ + 'email' => $this->email, + 'password' => $this->password, + 'password_confirmation' => $this->password_confirmation, + 'token' => $this->token, + ], + function ($user) { + $user->forceFill([ + 'password' => Hash::make($this->password), + 'must_change_password' => false, + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + }, + ); + + if ($status !== Password::PasswordReset) { + throw ValidationException::withMessages(['email' => __('auth.reset_token_invalid')]); + } + + session()->flash('status', __('auth.reset_done')); + + return $this->redirect(route('login'), navigate: true); + } + + public function render() + { + return view('livewire.auth.reset-password')->title(__('auth.title_forgot')); + } +} diff --git a/resources/views/livewire/auth/reset-password.blade.php b/resources/views/livewire/auth/reset-password.blade.php new file mode 100644 index 0000000..7ee353c --- /dev/null +++ b/resources/views/livewire/auth/reset-password.blade.php @@ -0,0 +1,35 @@ +@php + $label = 'mb-1.5 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; + $field = 'h-11 w-full rounded-md border border-line bg-inset px-3 text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; + $err = 'mt-1.5 flex items-center gap-1.5 font-mono text-[11px] text-offline'; +@endphp +
+
+

{{ __('auth.security') }}

+

{{ __('auth.forgot_heading') }}

+

{{ __('auth.forgot_subtitle') }}

+
+ +
+
+ + + @error('email')

{{ $message }}

@enderror +
+
+ + + @error('password')

{{ $message }}

@enderror +
+
+ + +
+ + {{ __('auth.forgot_submit') }} + {{ __('auth.saving') }} + +
+ + {{ __('auth.back_to_login') }} +
diff --git a/routes/web.php b/routes/web.php index bb6b191..5ecadfa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -44,6 +44,7 @@ Route::get('/_caddy/ask', function (Request $request, DeploymentService $deploym Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/forgot-password', Auth\ForgotPassword::class)->name('password.request'); + Route::get('/reset-password/{token}', Auth\ResetPassword::class)->name('password.reset'); Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge'); }); diff --git a/tests/Feature/EmailResetTest.php b/tests/Feature/EmailResetTest.php new file mode 100644 index 0000000..2937c3a --- /dev/null +++ b/tests/Feature/EmailResetTest.php @@ -0,0 +1,52 @@ + 'log']); + $this->assertFalse((new ForgotPassword)->mailEnabled()); + } + + public function test_send_link_dispatches_notification_when_mail_configured(): void + { + config(['mail.default' => 'smtp']); + Notification::fake(); + $user = User::factory()->create(['email' => 'admin@clusev.local']); + + Livewire::test(ForgotPassword::class) + ->set('email', 'admin@clusev.local') + ->call('sendResetLink') + ->assertHasNoErrors(); + + Notification::assertSentTo($user, ResetPasswordNotification::class); + } + + public function test_reset_password_with_token_sets_new_password(): void + { + $user = User::factory()->create(); + $token = Password::createToken($user); + + Livewire::test(ResetPassword::class, ['token' => $token]) + ->set('email', $user->email) + ->set('password', 'NewPassw0rd123')->set('password_confirmation', 'NewPassw0rd123') + ->call('resetPassword')->assertHasNoErrors(); + + $this->assertTrue(Hash::check('NewPassw0rd123', $user->fresh()->password)); + } +}