feature(resend otp): users can resend OTP in OTP verification page
This commit is contained in:
parent
d03aa31f30
commit
aa8ad6b84b
@ -67,3 +67,6 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
TWILIO_SID=
|
TWILIO_SID=
|
||||||
TWILIO_AUTH_TOKEN=
|
TWILIO_AUTH_TOKEN=
|
||||||
TWILIO_NUMBER=
|
TWILIO_NUMBER=
|
||||||
|
|
||||||
|
# OTP valid time in minutes
|
||||||
|
OTP_LIFESPAN=10
|
||||||
|
|||||||
29
app/Actions/PasswordReset/ResendOTPAction.php
Normal file
29
app/Actions/PasswordReset/ResendOTPAction.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\PasswordReset;
|
||||||
|
|
||||||
|
use App\Exceptions\UserNotFoundException;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OTPService;
|
||||||
|
|
||||||
|
final readonly class ResendOTPAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OTPService $otpService,
|
||||||
|
private SendOTPToUserAction $otpToUserAction
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function execute(): void
|
||||||
|
{
|
||||||
|
$user = \Session::get('otp_user_id') ? User::find(\Session::get('otp_user_id')) : null;
|
||||||
|
throw_if(! $user, new UserNotFoundException('User not found'));
|
||||||
|
|
||||||
|
$otp = $this->otpService->generate($user);
|
||||||
|
|
||||||
|
$this->otpToUserAction->execute(['user' => $user, 'otp' => $otp]);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Actions/PasswordReset/SendOTPAction.php
Normal file
30
app/Actions/PasswordReset/SendOTPAction.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\PasswordReset;
|
||||||
|
|
||||||
|
use App\Exceptions\UserNotFoundException;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OTPService;
|
||||||
|
|
||||||
|
final readonly class SendOTPAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OTPService $otpService,
|
||||||
|
private SendOTPToUserAction $otpToUserAction
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function execute(array $data): void
|
||||||
|
{
|
||||||
|
$user = User::where('email', $data['email'])->first();
|
||||||
|
throw_if(! $user, new UserNotFoundException('User not found'));
|
||||||
|
|
||||||
|
\Session::put('otp_user_id', $user->id);
|
||||||
|
$otp = $this->otpService->generate($user);
|
||||||
|
|
||||||
|
$this->otpToUserAction->execute(['user' => $user, 'otp' => $otp]);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,30 +1,27 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Actions;
|
namespace App\Actions\PasswordReset;
|
||||||
|
|
||||||
use App\Exceptions\UserNotFoundException;
|
use App\Actions\SendPasswordResetMailAction;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\OTPService;
|
|
||||||
use App\Services\TwilioService;
|
use App\Services\TwilioService;
|
||||||
use Twilio\Exceptions\TwilioException;
|
use Twilio\Exceptions\TwilioException;
|
||||||
|
|
||||||
final readonly class PasswordResetAction
|
final readonly class SendOTPToUserAction
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private SendPasswordResetMailAction $mailAction,
|
private SendPasswordResetMailAction $mailAction,
|
||||||
private OTPService $otpService,
|
|
||||||
private TwilioService $twilioService
|
private TwilioService $twilioService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*
|
||||||
* @throws \Throwable
|
* @throws \Throwable
|
||||||
*/
|
*/
|
||||||
public function execute(array $data): void
|
public function execute(array $data): void
|
||||||
{
|
{
|
||||||
$user = User::where('email', $data['email'])->first();
|
['user' => $user,'otp' => $otp] = $data;
|
||||||
throw_if(! $user, new UserNotFoundException('User not found'));
|
|
||||||
|
|
||||||
$otp = $this->otpService->generate($user);
|
|
||||||
$this->mailAction->execute($user->email, $otp);
|
$this->mailAction->execute($user->email, $otp);
|
||||||
|
|
||||||
if ($user?->type->phone !== null) {
|
if ($user?->type->phone !== null) {
|
||||||
30
app/Actions/PasswordReset/VerifyOTPAction.php
Normal file
30
app/Actions/PasswordReset/VerifyOTPAction.php
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\PasswordReset;
|
||||||
|
|
||||||
|
use App\Exceptions\UserNotFoundException;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\OTPService;
|
||||||
|
|
||||||
|
final readonly class VerifyOTPAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private OTPService $otpService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \Throwable
|
||||||
|
*/
|
||||||
|
public function execute(array $data): bool
|
||||||
|
{
|
||||||
|
$user = \Session::get('otp_user_id') ? User::find(\Session::get('otp_user_id')) : null;
|
||||||
|
throw_if(! $user, new UserNotFoundException('User not found'));
|
||||||
|
|
||||||
|
$isVerified = $this->otpService->verify($user, $data['otp']);
|
||||||
|
if ($isVerified) {
|
||||||
|
\Session::forget('otp_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $isVerified;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Actions\PasswordResetAction;
|
use App\Actions\PasswordReset\ResendOTPAction;
|
||||||
|
use App\Actions\PasswordReset\SendOTPAction;
|
||||||
|
use App\Actions\PasswordReset\VerifyOTPAction;
|
||||||
use App\Exceptions\UserNotFoundException;
|
use App\Exceptions\UserNotFoundException;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OTPService;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Session;
|
use Illuminate\Support\Facades\Session;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@ -18,7 +19,7 @@ public function show()
|
|||||||
return view('auth.passwords.reset');
|
return view('auth.passwords.reset');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendCode(Request $request, PasswordResetAction $action)
|
public function sendCode(Request $request, SendOTPAction $action)
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
@ -27,7 +28,8 @@ public function sendCode(Request $request, PasswordResetAction $action)
|
|||||||
try {
|
try {
|
||||||
$action->execute($data);
|
$action->execute($data);
|
||||||
|
|
||||||
return to_route('password.reset.show.verify')->with('success', 'Password reset code is sent');
|
return to_route('password.reset.show.verify')
|
||||||
|
->with('success', 'Password reset code is sent');
|
||||||
} catch (UserNotFoundException $e) {
|
} catch (UserNotFoundException $e) {
|
||||||
return to_route('password.reset.show.verify')->with('success', 'Password reset code is sent');
|
return to_route('password.reset.show.verify')->with('success', 'Password reset code is sent');
|
||||||
}
|
}
|
||||||
@ -35,14 +37,15 @@ public function sendCode(Request $request, PasswordResetAction $action)
|
|||||||
|
|
||||||
public function showVerify()
|
public function showVerify()
|
||||||
{
|
{
|
||||||
return view('auth.passwords.verify');
|
return view('auth.passwords.verify')
|
||||||
|
->with('expiryMinutes', 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function verify(Request $request, OTPService $otpService)
|
public function verify(Request $request, VerifyOTPAction $otpAction)
|
||||||
{
|
{
|
||||||
$data = $request->validate(['otp' => 'required|string:min:5:max:6']);
|
$data = $request->validate(['otp' => 'required|string:min:5:max:6']);
|
||||||
try {
|
try {
|
||||||
$isVerified = $otpService->verify($data['otp']);
|
$isVerified = $otpAction->execute($data);
|
||||||
if (! $isVerified) {
|
if (! $isVerified) {
|
||||||
return back()->with('error', 'Invalid OTP');
|
return back()->with('error', 'Invalid OTP');
|
||||||
}
|
}
|
||||||
@ -60,7 +63,9 @@ public function showUpdate()
|
|||||||
|
|
||||||
public function update(Request $request)
|
public function update(Request $request)
|
||||||
{
|
{
|
||||||
$data = $request->validate(['password' => 'required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols()]);
|
$data = $request->validate([
|
||||||
|
'password' => 'required', 'confirmed', Password::min(8)->letters()->mixedCase()->numbers()->symbols(),
|
||||||
|
]);
|
||||||
$user = User::find(Session::get('user_id'));
|
$user = User::find(Session::get('user_id'));
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
return back()->with('error', 'Session Expired');
|
return back()->with('error', 'Session Expired');
|
||||||
@ -69,4 +74,16 @@ public function update(Request $request)
|
|||||||
|
|
||||||
return to_route('login.create')->with('success', 'Password updated successfully');
|
return to_route('login.create')->with('success', 'Password updated successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resend(ResendOTPAction $otpAction)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$otpAction->execute();
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,34 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Exceptions\UserNotFoundException;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Session;
|
|
||||||
|
|
||||||
class OTPService
|
class OTPService
|
||||||
{
|
{
|
||||||
public function generate(User $user, int $length = 6): string
|
public function generate(User $user, int $length = 6): string
|
||||||
{
|
{
|
||||||
$code = \Str::random($length);
|
$code = \Str::random($length);
|
||||||
|
Cache::put("otp_$user->id", $code, now()->addMinutes((int) config('auth.otp_lifespan', '10')));
|
||||||
Cache::put("otp_$user->id", $code, now()->addMinutes(10));
|
|
||||||
Session::put('user_id', $user->id);
|
|
||||||
|
|
||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function verify(User $user, string $otp): bool
|
||||||
* @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");
|
$code = Cache::get("otp_$user->id");
|
||||||
if ($code === $otp) {
|
if ($code === $otp) {
|
||||||
Cache::forget("otp_$user->id");
|
Cache::forget("otp_$user->id");
|
||||||
|
|||||||
@ -111,5 +111,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||||
|
'otp_lifespan' => env('OTP_LIFESPAN', 10),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<x-layout title="Password Reset">
|
<x-layout title="Password Reset">
|
||||||
<section
|
<section class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-center items-center wrapper">
|
||||||
class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-center items-center wrapper">
|
|
||||||
<div class="absolute md:top-20 top-10 left-4 md:left-10">
|
<div class="absolute md:top-20 top-10 left-4 md:left-10">
|
||||||
<a href="{{route('password.reset.show')}}" class="flex hover:underline">
|
<a href="{{route('password.reset.show')}}" class="flex hover:underline">
|
||||||
<x-heroicon-o-arrow-left class="w-4 mr-2"/>
|
<x-heroicon-o-arrow-left class="w-4 mr-2"/>
|
||||||
@ -31,6 +30,49 @@ class="bg-linear-135 h-screen from-[#EFF6FF] to-[#FCF3F8] flex flex-col justify-
|
|||||||
<x-ui.button variant="neutral">Reset Password</x-ui.button>
|
<x-ui.button variant="neutral">Reset Password</x-ui.button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div id="otp-timer-container" class="mt-3 text-center text-sm text-gray-600">
|
||||||
|
<div id="timer-display">
|
||||||
|
Didn't receive code? Resend in
|
||||||
|
<span id="countdown-timer" class="font-mono font-bold text-blue-600">--:--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resend-action" class="hidden">
|
||||||
|
<p class="mb-2">Didn't receive the email or SMS?</p>
|
||||||
|
<a href="{{ route('password.reset.resend') }}"
|
||||||
|
class="text-blue-600 hover:underline font-bold">
|
||||||
|
Resend OTP
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</x-ui.card>
|
</x-ui.card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let secondsLeft = {{ ($expiryMinutes ?? 0) * 60 }};
|
||||||
|
|
||||||
|
const timerDisplay = document.getElementById('timer-display');
|
||||||
|
const resendAction = document.getElementById('resend-action');
|
||||||
|
const countdownSpan = document.getElementById('countdown-timer');
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
if (secondsLeft <= 0) {
|
||||||
|
timerDisplay.classList.add('hidden');
|
||||||
|
resendAction.classList.remove('hidden');
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(secondsLeft / 60);
|
||||||
|
const seconds = secondsLeft % 60;
|
||||||
|
|
||||||
|
countdownSpan.textContent = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
|
||||||
|
secondsLeft--;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimer();
|
||||||
|
const interval = setInterval(updateTimer, 950);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</x-layout>
|
</x-layout>
|
||||||
|
|||||||
@ -1,9 +1,102 @@
|
|||||||
<x-mail::message>
|
<!DOCTYPE html>
|
||||||
|
<html style="height: 600px;">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>OTP Email</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
# OTP: {{$otp}}
|
table {
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
You are receiving this email because we received a password reset request for your account.
|
img {
|
||||||
If you did not request a password reset, no further action is required.
|
border: 0;
|
||||||
Thanks,
|
height: auto;
|
||||||
{{ config('app.name') }}
|
line-height: 100%;
|
||||||
</x-mail::message>
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
style="background-color: #f4f6f8; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;display: flex; justify-content: center; align-items: center; height: 100%;">
|
||||||
|
|
||||||
|
<table style="background-color: #f4f6f8; padding: 40px 0;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
<table class="container"
|
||||||
|
style="width: 600px; max-width: 600px; background-color: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, #EFF6FF 0%, #FCF3F8 100%); padding: 30px 40px; height: 120px; vertical-align: top;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="left"
|
||||||
|
style="color: #000000; display: flex; align-items: center;">
|
||||||
|
<p style="padding: 12px; margin-right: 8px; width: fit-content; border-radius: 8px; background: linear-gradient(120deg, #136FFA 0%, #806CF9 100%); color: #ffffff; font-weight: bold ">DH</p>
|
||||||
|
<p style="font-size: 30px; font-weight: bold; letter-spacing: 1px; margin-bottom: 20px;">{{config('app.name')}}</p>
|
||||||
|
</td>
|
||||||
|
<td align="right" style="color: #000000; font-size: 14px;">
|
||||||
|
{{ now()->format('d M, Y') }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 40px; text-align: center;">
|
||||||
|
|
||||||
|
<h2 style="margin: 0 0 10px 0; color: #1a1a1a; font-size: 24px; font-weight: 600;">Your OTP</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 30px 0; color: #718096; font-size: 14px; line-height: 1.6;">
|
||||||
|
Thank you for choosing {{config('app.name')}}. Use the following OTP to complete the
|
||||||
|
procedure to change your reset your password. OTP is valid for
|
||||||
|
<strong>{{config('auth.otp_lifespan')}} minutes</strong>. Do not share this code with
|
||||||
|
others.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="margin: 30px 0;">
|
||||||
|
@foreach(str_split((string)$otp) as $digit)
|
||||||
|
<span
|
||||||
|
style="display: inline-block; margin: 0 5px; color: #c0394e; font-size: 32px; font-weight: bold;">
|
||||||
|
{{ $digit }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td height="20" style="background-color: #ffffff;"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -16,15 +16,22 @@
|
|||||||
Route::prefix('password/reset')->name('password.reset.')->middleware('throttle:20,1')->group(function () {
|
Route::prefix('password/reset')->name('password.reset.')->middleware('throttle:20,1')->group(function () {
|
||||||
Route::controller(PasswordResetController::class)->group(function () {
|
Route::controller(PasswordResetController::class)->group(function () {
|
||||||
Route::get('/', 'show')->name('show');
|
Route::get('/', 'show')->name('show');
|
||||||
|
|
||||||
Route::post('/', 'sendCode')->name('send');
|
Route::post('/', 'sendCode')->name('send');
|
||||||
|
|
||||||
Route::get('/verify',
|
Route::get('/verify',
|
||||||
'showVerify')->name('show.verify');
|
'showVerify')->name('show.verify');
|
||||||
|
|
||||||
Route::post('/verify',
|
Route::post('/verify',
|
||||||
'verify')->name('verify');
|
'verify')->name('verify');
|
||||||
|
|
||||||
Route::get('/update',
|
Route::get('/update',
|
||||||
'showUpdate')->name('show.update');
|
'showUpdate')->name('show.update');
|
||||||
|
|
||||||
Route::post('/update',
|
Route::post('/update',
|
||||||
'update')->name('update');
|
'update')->name('update');
|
||||||
|
|
||||||
|
Route::get('/resend', 'resend')->name('resend');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user