feature(password reset): guests can reset theri passwords via email

This commit is contained in:
kusowl 2026-01-29 17:32:02 +05:30
parent 38d429e5d5
commit 039f8f5568
15 changed files with 343 additions and 5 deletions

View File

@ -0,0 +1,24 @@
<?php
namespace App\Actions;
use App\Exceptions\UserNotFoundException;
use App\Models\User;
use App\Services\OTPService;
final readonly class PasswordResetAction
{
public function __construct(private SendPasswordResetMailAction $mailAction, private OTPService $otpService) {}
/**
* @throws \Throwable
*/
public function execute(array $data): void
{
$user = User::where('email', $data['email'])->first();
throw_if(! $user, new UserNotFoundException('User not found'));
$otp = $this->otpService->generate($user);
$this->mailAction->execute($user->email, $otp);
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Actions;
use App\Mail\PasswordResetMail;
final readonly class SendPasswordResetMailAction
{
/**
* @throws \Throwable
*/
public function execute(string $email, string $otp): void
{
try {
$message = \Mail::to($email)->send(new PasswordResetMail($otp));
\Log::info('Mail sent successfully', ['message' => $message]);
} catch (\Throwable $e) {
\Log::info('Mail send failed', ['message' => $e->getMessage()]);
}
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use Exception;
class UserNotFoundException extends Exception {}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Actions\PasswordResetAction;
use App\Exceptions\UserNotFoundException;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\OTPService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rules\Password;
class PasswordResetController extends Controller
{
public function show()
{
return view('auth.passwords.reset');
}
public function sendCode(Request $request, PasswordResetAction $action)
{
$data = $request->validate([
'email' => 'required|email',
]);
try {
$action->execute($data);
return to_route('password.reset.show.verify')->with('success', 'Password reset code is sent');
} catch (UserNotFoundException $e) {
return to_route('password.reset.show.verify')->with('success', 'Password reset code is sent');
}
}
public function showVerify()
{
return view('auth.passwords.verify');
}
public function verify(Request $request, OTPService $otpService)
{
$data = $request->validate(['otp' => 'required|string:min:5:max:6']);
try {
$isVerified = $otpService->verify($data['otp']);
if (! $isVerified) {
return back()->with('error', 'Invalid OTP');
}
return to_route('password.reset.show.update')->with('success', 'OTP Verified');
} catch (UserNotFoundException $e) {
return back()->with('error', 'Session Expired');
}
}
public function showUpdate()
{
return view('auth.passwords.update');
}
public function update(Request $request)
{
$data = $request->validate(['password' => 'required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols()]);
$user = User::find(Session::get('user_id'));
if (! $user) {
return back()->with('error', 'Session Expired');
}
$user->update(['password' => $data['password']]);
return to_route('login.create')->with('success', 'Password updated successfully');
}
}

View File

@ -45,7 +45,7 @@ public function store(StoreRegisterdUser $request)
});
return to_route('login.create')
->with('userRegistered', 'User registered successfully.');
->with('success', 'User registered successfully.');
} catch (\Throwable $e) {
Log::error('Registration Failed: '.$e->getMessage());

View File

@ -0,0 +1,38 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PasswordResetMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(private readonly string $otp) {}
public function envelope(): Envelope
{
return new Envelope(
from: new Address(config('mail.from.address'), config('mail.from.name')),
subject: 'Password Reset',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.password-reset',
with: ['otp' => $this->otp]
);
}
public function attachments(): array
{
return [];
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Services;
use App\Exceptions\UserNotFoundException;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Session;
class OTPService
{
public function generate(User $user, int $length = 6): string
{
$code = \Str::random($length);
Cache::put("otp_$user->id", $code, now()->addMinutes(10));
Session::put('user_id', $user->id);
return $code;
}
/**
* @throws \Exception
*/
public function verify(string $otp): bool
{
$user = User::find(Session::get('user_id'));
if (! $user) {
throw new UserNotFoundException('User not found');
}
$code = Cache::get("otp_$user->id");
if ($code === $otp) {
Cache::forget("otp_$user->id");
return true;
}
return false;
}
}

View File

@ -20,7 +20,7 @@ class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-
</x-ui.alert>
@endsession
@session('userRegistered')
@session('success')
<x-ui.alert variant="success">
{{$value}}
</x-ui.alert>
@ -35,7 +35,7 @@ class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-
<x-ui.input type="checkbox" name="remember_me"/>
<label class="text-sm font-bold text-accent-600">Remember me</label>
</div>
<a class="text-blue-500 font-bold text-sm" href="">Forgot password?</a>
<a class="text-blue-500 font-bold text-sm" href="{{route('password.reset.show')}}">Forgot password?</a>
</div>
<x-ui.button variant="neutral">Sign In</x-ui.button>
</form>

View File

@ -0,0 +1,36 @@
<x-layout title="Password Reset">
<section
class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-center items-center wrapper">
<div class="mb-12 self-start">
<a href="{{route('login.create')}}" class="flex hover:underline">
<x-heroicon-o-arrow-left class="w-4 mr-2"/>
Back to Login
</a>
</div>
<x-ui.card class="sm:w-96 w-full">
<div class="flex flex-col items-center space-y-5">
<x-logo class="w-fit"/>
<h1 class="font-bold text-2xl">Reset Password</h1>
</div>
@session('error')
<x-ui.alert variant="error">
{{$value}}
</x-ui.alert>
@endsession
@session('success')
<x-ui.alert variant="success">
{{$value}}
</x-ui.alert>
@endsession
<form action="{{route('password.reset.send')}}" method="post" class="flex flex-col space-y-5">
@csrf
<x-ui.input label="Email" description="An OTP will be send to your email and phone number" name="email" type="email" placeholder="you@example.com"/>
<x-ui.button variant="neutral">Reset Password</x-ui.button>
</form>
</x-ui.card>
</section>
</x-layout>

View File

@ -0,0 +1,37 @@
<x-layout title="Password Reset">
<section
class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-center items-center wrapper">
<div class="mb-12 self-start">
<a href="{{route('login.create')}}" class="flex hover:underline">
<x-heroicon-o-arrow-left class="w-4 mr-2"/>
Back to Login
</a>
</div>
<x-ui.card class="sm:w-96 w-full">
<div class="flex flex-col items-center space-y-5">
<x-logo class="w-fit"/>
<h1 class="font-bold text-2xl">Reset Password</h1>
</div>
@session('error')
<x-ui.alert variant="error">
{{$value}}
</x-ui.alert>
@endsession
@session('success')
<x-ui.alert variant="success">
{{$value}}
</x-ui.alert>
@endsession
<form action="{{route('password.reset.update')}}" method="post" class="flex flex-col space-y-5">
@csrf
<x-ui.input label="New Password" name="password" type="password"/>
<x-ui.input label="Confirm Password" name="password_confirmation" type="text"/>
<x-ui.button variant="neutral">Reset Password</x-ui.button>
</form>
</x-ui.card>
</section>
</x-layout>

View File

@ -0,0 +1,36 @@
<x-layout title="Password Reset">
<section
class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-center items-center wrapper">
<div class="mb-12 self-start">
<a href="{{route('password.reset.show')}}" class="flex hover:underline">
<x-heroicon-o-arrow-left class="w-4 mr-2"/>
Back to Reset password
</a>
</div>
<x-ui.card class="sm:w-96 w-full">
<div class="flex flex-col items-center space-y-5">
<x-logo class="w-fit"/>
<h1 class="font-bold text-2xl">Verify OTP</h1>
</div>
@session('error')
<x-ui.alert variant="error">
{{$value}}
</x-ui.alert>
@endsession
@session('success')
<x-ui.alert variant="success">
{{$value}}
</x-ui.alert>
@endsession
<form action="{{route('password.reset.verify')}}" method="post" class="flex flex-col space-y-5">
@csrf
<x-ui.input label="OTP" description="Type OTP that send in your email or vis SMS" name="otp" type="text"/>
<x-ui.button variant="neutral">Reset Password</x-ui.button>
</form>
</x-ui.card>
</section>
</x-layout>

View File

@ -14,7 +14,7 @@
<title>{{ $pageTitle }}</title>
<link rel="shortcut icon" type="image/x-icon" href="{{asset('storage/'.'/images/favicon.ico')}}"/>
<link rel="shortcut icon" type="image/x-icon" href="{{asset('storage/'.'images/favicon.ico')}}"/>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet"/>

View File

@ -16,7 +16,7 @@
>
{{$slot}}
@if($description !== '')
<p class="text-accent-600 text-xs">{{$description}}</p>
<p class="text-accent-600 text-xs mt-1">{{$description}}</p>
@endif
<x-ui.inline-error :name="$name"/>
</div>

View File

@ -0,0 +1,9 @@
<x-mail::message>
# OTP: {{$otp}}
You are receiving this email because we received a password reset request for your account.
If you did not request a password reset, no further action is required.
Thanks,
{{ config('app.name') }}
</x-mail::message>

View File

@ -3,6 +3,7 @@
use App\Enums\UserTypes;
use App\Http\Controllers\Auth\AuthenticatedUserController;
use App\Http\Controllers\Auth\ImpersonatedUserController;
use App\Http\Controllers\Auth\PasswordResetController;
use App\Http\Controllers\Auth\RegisteredUserController;
use App\Http\Middleware\HasRole;
@ -11,6 +12,21 @@
->middleware('throttle:6,1')
->only(['create', 'store']);
Route::resource('/register', RegisteredUserController::class)->only(['create', 'store']);
Route::prefix('password/reset')->name('password.reset.')->middleware('throttle:20,1')->group(function () {
Route::controller(PasswordResetController::class)->group(function () {
Route::get('/', 'show')->name('show');
Route::post('/', 'sendCode')->name('send');
Route::get('/verify',
'showVerify')->name('show.verify');
Route::post('/verify',
'verify')->name('verify');
Route::get('/update',
'showUpdate')->name('show.update');
Route::post('/update',
'update')->name('update');
});
});
});
Route::delete('/logout', [AuthenticatedUserController::class, 'destroy'])