feature(password reset): guests can reset theri passwords via email
This commit is contained in:
parent
38d429e5d5
commit
039f8f5568
24
app/Actions/PasswordResetAction.php
Normal file
24
app/Actions/PasswordResetAction.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Actions/SendPasswordResetMailAction.php
Normal file
21
app/Actions/SendPasswordResetMailAction.php
Normal 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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/Exceptions/UserNotFoundException.php
Normal file
7
app/Exceptions/UserNotFoundException.php
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class UserNotFoundException extends Exception {}
|
||||||
72
app/Http/Controllers/Auth/PasswordResetController.php
Normal file
72
app/Http/Controllers/Auth/PasswordResetController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ public function store(StoreRegisterdUser $request)
|
|||||||
});
|
});
|
||||||
|
|
||||||
return to_route('login.create')
|
return to_route('login.create')
|
||||||
->with('userRegistered', 'User registered successfully.');
|
->with('success', 'User registered successfully.');
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('Registration Failed: '.$e->getMessage());
|
Log::error('Registration Failed: '.$e->getMessage());
|
||||||
|
|||||||
38
app/Mail/PasswordResetMail.php
Normal file
38
app/Mail/PasswordResetMail.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Services/OTPService.php
Normal file
42
app/Services/OTPService.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@ class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-
|
|||||||
</x-ui.alert>
|
</x-ui.alert>
|
||||||
@endsession
|
@endsession
|
||||||
|
|
||||||
@session('userRegistered')
|
@session('success')
|
||||||
<x-ui.alert variant="success">
|
<x-ui.alert variant="success">
|
||||||
{{$value}}
|
{{$value}}
|
||||||
</x-ui.alert>
|
</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"/>
|
<x-ui.input type="checkbox" name="remember_me"/>
|
||||||
<label class="text-sm font-bold text-accent-600">Remember me</label>
|
<label class="text-sm font-bold text-accent-600">Remember me</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<x-ui.button variant="neutral">Sign In</x-ui.button>
|
<x-ui.button variant="neutral">Sign In</x-ui.button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
36
resources/views/auth/passwords/reset.blade.php
Normal file
36
resources/views/auth/passwords/reset.blade.php
Normal 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>
|
||||||
37
resources/views/auth/passwords/update.blade.php
Normal file
37
resources/views/auth/passwords/update.blade.php
Normal 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>
|
||||||
36
resources/views/auth/passwords/verify.blade.php
Normal file
36
resources/views/auth/passwords/verify.blade.php
Normal 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>
|
||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
|
|
||||||
<title>{{ $pageTitle }}</title>
|
<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 -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet"/>
|
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet"/>
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
>
|
>
|
||||||
{{$slot}}
|
{{$slot}}
|
||||||
@if($description !== '')
|
@if($description !== '')
|
||||||
<p class="text-accent-600 text-xs">{{$description}}</p>
|
<p class="text-accent-600 text-xs mt-1">{{$description}}</p>
|
||||||
@endif
|
@endif
|
||||||
<x-ui.inline-error :name="$name"/>
|
<x-ui.inline-error :name="$name"/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
9
resources/views/emails/password-reset.blade.php
Normal file
9
resources/views/emails/password-reset.blade.php
Normal 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>
|
||||||
@ -3,6 +3,7 @@
|
|||||||
use App\Enums\UserTypes;
|
use App\Enums\UserTypes;
|
||||||
use App\Http\Controllers\Auth\AuthenticatedUserController;
|
use App\Http\Controllers\Auth\AuthenticatedUserController;
|
||||||
use App\Http\Controllers\Auth\ImpersonatedUserController;
|
use App\Http\Controllers\Auth\ImpersonatedUserController;
|
||||||
|
use App\Http\Controllers\Auth\PasswordResetController;
|
||||||
use App\Http\Controllers\Auth\RegisteredUserController;
|
use App\Http\Controllers\Auth\RegisteredUserController;
|
||||||
use App\Http\Middleware\HasRole;
|
use App\Http\Middleware\HasRole;
|
||||||
|
|
||||||
@ -11,6 +12,21 @@
|
|||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->only(['create', 'store']);
|
->only(['create', 'store']);
|
||||||
Route::resource('/register', RegisteredUserController::class)->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'])
|
Route::delete('/logout', [AuthenticatedUserController::class, 'destroy'])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user