From 3b67e0bfb71055efad1d19d1cdea37ff3f43a0e3 Mon Sep 17 00:00:00 2001 From: Norman Huth Date: Sun, 25 Jan 2026 13:13:53 +0100 Subject: [PATCH] feat(auth): add password reset functionality - Implement `ForgotPassword` and `ResetPassword` Livewire components for password recovery - Add routes for forgot and reset password pages - Update login page with "Forgot password?" link - Create views for forgot password and reset password forms - Customize password reset notification to support portal-specific URLs --- app/Livewire/Portal/Auth/ForgotPassword.php | 57 ++++++++++++ app/Livewire/Portal/Auth/ResetPassword.php | 92 +++++++++++++++++++ .../ResetPasswordNotification.php | 7 +- .../livewire/auth/forgot-password.blade.php | 37 ++++++++ .../portal/livewire/auth/login.blade.php | 5 +- .../livewire/auth/reset-password.blade.php | 39 ++++++++ routes/portal.php | 4 + 7 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 app/Livewire/Portal/Auth/ForgotPassword.php create mode 100644 app/Livewire/Portal/Auth/ResetPassword.php create mode 100644 resources/views/portal/livewire/auth/forgot-password.blade.php create mode 100644 resources/views/portal/livewire/auth/reset-password.blade.php diff --git a/app/Livewire/Portal/Auth/ForgotPassword.php b/app/Livewire/Portal/Auth/ForgotPassword.php new file mode 100644 index 00000000..3c5ff86d --- /dev/null +++ b/app/Livewire/Portal/Auth/ForgotPassword.php @@ -0,0 +1,57 @@ +validate([ + 'email' => ['required', 'email'], + ]); + + ResetPassword::createUrlUsing(fn (User $user, string $token): string => route('portal.password.reset', [ + 'token' => $token, + ])); + + $status = Password::sendResetLink(['email' => $this->email]); + + if ($status === Password::RESET_LINK_SENT) { + $this->linkSent = true; + Flux::toastSuccess(__($status)); + + return; + } + + throw ValidationException::withMessages([ + 'email' => __($status), + ]); + } + + public function render(): View + { + return view('portal::livewire.auth.forgot-password') + ->title('Forgot Password'); + } +} diff --git a/app/Livewire/Portal/Auth/ResetPassword.php b/app/Livewire/Portal/Auth/ResetPassword.php new file mode 100644 index 00000000..2f0e4603 --- /dev/null +++ b/app/Livewire/Portal/Auth/ResetPassword.php @@ -0,0 +1,92 @@ +token = $token; + $this->email = $email ?? ''; + } + + /** + * Reset the user's password. + * + * @throws ValidationException + */ + public function resetPassword(): void + { + $this->validate([ + 'token' => ['required'], + 'email' => ['required', 'email'], + 'password' => ['required', 'confirmed', PasswordRule::defaults()], + ]); + + $status = Password::reset( + [ + 'email' => $this->email, + 'password' => $this->password, + 'password_confirmation' => $this->password_confirmation, + 'token' => $this->token, + ], + function (User $user, string $password): void { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + + event(new PasswordReset($user)); + } + ); + + if ($status === Password::PASSWORD_RESET) { + Auth::attempt(['email' => $this->email, 'password' => $this->password], true); + Session::regenerate(); + + Flux::toastSuccess(__($status)); + + $this->redirect(route('portal.dashboard'), navigate: true); + + return; + } + + throw ValidationException::withMessages([ + 'email' => __($status), + ]); + } + + public function render(): View + { + return view('portal::livewire.auth.reset-password') + ->title('Reset Password'); + } +} diff --git a/app/Notifications/ResetPasswordNotification.php b/app/Notifications/ResetPasswordNotification.php index 6780618b..5d5d8c85 100644 --- a/app/Notifications/ResetPasswordNotification.php +++ b/app/Notifications/ResetPasswordNotification.php @@ -32,9 +32,14 @@ public function toMail(mixed $notifiable): MailMessage #[Override] protected function resetUrl(mixed $notifiable): string { + // Check if a custom URL callback was set (e.g., by Portal) + if (static::$createUrlCallback) { + return call_user_func(static::$createUrlCallback, $notifiable, $this->token); + } + + // Default: use localized route for web return localized_route('localized.password.reset', [ 'token' => $this->token, - 'email' => $notifiable->getEmailForPasswordReset(), ]); } } diff --git a/resources/views/portal/livewire/auth/forgot-password.blade.php b/resources/views/portal/livewire/auth/forgot-password.blade.php new file mode 100644 index 00000000..588aedd5 --- /dev/null +++ b/resources/views/portal/livewire/auth/forgot-password.blade.php @@ -0,0 +1,37 @@ +
+
+ Forgot your password? + + Enter your email address and we'll send you a link to reset your password. + +
+ + @if ($linkSent) + + We have emailed your password reset link. Please check your inbox. + + +
+ Back to login +
+ @else +
+ + + + Send reset link + + + +
+ Back to login +
+ @endif +
diff --git a/resources/views/portal/livewire/auth/login.blade.php b/resources/views/portal/livewire/auth/login.blade.php index 88640219..63e7498b 100644 --- a/resources/views/portal/livewire/auth/login.blade.php +++ b/resources/views/portal/livewire/auth/login.blade.php @@ -24,7 +24,10 @@ required /> - +
+ + Forgot password? +
Sign in diff --git a/resources/views/portal/livewire/auth/reset-password.blade.php b/resources/views/portal/livewire/auth/reset-password.blade.php new file mode 100644 index 00000000..bd608992 --- /dev/null +++ b/resources/views/portal/livewire/auth/reset-password.blade.php @@ -0,0 +1,39 @@ +
+
+ Reset your password + + Enter your new password below. + +
+ +
+ + + + + + + + Reset password + + +
diff --git a/routes/portal.php b/routes/portal.php index f521425b..de29cd74 100644 --- a/routes/portal.php +++ b/routes/portal.php @@ -6,8 +6,10 @@ use App\Livewire\Portal\Admin\ApiUsage; use App\Livewire\Portal\Admin\UserIndex; use App\Livewire\Portal\Admin\UserShow; +use App\Livewire\Portal\Auth\ForgotPassword; use App\Livewire\Portal\Auth\Login; use App\Livewire\Portal\Auth\Register; +use App\Livewire\Portal\Auth\ResetPassword; use App\Livewire\Portal\Auth\VerifyEmail; use App\Livewire\Portal\Changelog; use App\Livewire\Portal\Dashboard; @@ -55,6 +57,8 @@ Route::middleware('guest')->group(function (): void { Route::get('login', Login::class)->name('login'); Route::get('register', Register::class)->name('register'); + Route::get('forgot-password', ForgotPassword::class)->name('password.request'); + Route::get('reset-password/{token}', ResetPassword::class)->name('password.reset'); }); // Public routes (accessible to everyone)