feature(resend otp): users can resend OTP in OTP verification page

This commit is contained in:
kusowl 2026-01-30 12:27:52 +05:30
parent d03aa31f30
commit aa8ad6b84b
11 changed files with 277 additions and 41 deletions

View File

@ -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

View 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]);
}
}

View 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]);
}
}

View File

@ -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) {

View 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;
}
}

View File

@ -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');
}
}
} }

View File

@ -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");

View File

@ -111,5 +111,6 @@
*/ */
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
'otp_lifespan' => env('OTP_LIFESPAN', 10),
]; ];

View File

@ -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>

View File

@ -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>

View File

@ -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');
}); });
}); });
}); });