update lupa pw

This commit is contained in:
bayughazali 2026-05-25 18:48:33 +07:00
parent 6ce214315c
commit aa29baab24
8 changed files with 758 additions and 332 deletions

View File

@ -10,14 +10,24 @@
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Carbon\Carbon;
use App\Services\FonnteService;
class AdminForgotPasswordController extends Controller
{
// ─────────────────────────────────────────────────────────
// STEP 1 — Tampilkan form input email
// ─────────────────────────────────────────────────────────
protected FonnteService $fonnte;
public function __construct(FonnteService $fonnte)
{
$this->fonnte = $fonnte;
}
public function showForgotForm()
{
return view('admin.forgot-password');
}
@ -25,7 +35,18 @@ public function showForgotForm()
// STEP 1 — Proses: validasi email & kirim OTP
// ─────────────────────────────────────────────────────────
public function sendOtp(Request $request)
{
{
$method = $request->input('method', 'email');
if ($method === 'whatsapp') {
return $this->sendOtpViaWhatsapp($request);
}
return $this->sendOtpViaEmail($request);
}
private function sendOtpViaEmail(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
], [
@ -43,14 +64,7 @@ public function sendOtp(Request $request)
])->withInput();
}
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
DB::table('password_reset_tokens')->insert([
'email' => $request->email,
'token' => Hash::make($otp),
'created_at' => Carbon::now(),
]);
$otp = $this->generateAndStoreOtp($request->email);
Mail::send('emails.admin-otp', [
'otp' => $otp,
@ -61,11 +75,76 @@ public function sendOtp(Request $request)
});
$request->session()->put('admin_reset_email', $request->email);
$request->session()->put('admin_reset_method', 'email');
return redirect()->route('admin.password.verify-otp-form')
->with('success', 'Kode OTP telah dikirim ke email admin Anda. Berlaku 10 menit.');
->with('success', 'Kode OTP telah dikirim ke email admin Anda. Berlaku 3 menit.');
}
private function sendOtpViaWhatsapp(Request $request)
{
$request->validate([
'phone' => ['required', 'digits_between:10,15'],
], [
'phone.required' => 'Nomor WhatsApp wajib diisi.',
'phone.digits_between' => 'Nomor WhatsApp tidak valid (10-15 digit).',
]);
$phoneFormatted = FonnteService::formatPhone($request->phone);
// Cari user dengan nomor WA ini — WAJIB is_admin = true
$admin = User::where('is_admin', true)
->where(function ($q) use ($phoneFormatted, $request) {
$q->where('phone', $phoneFormatted)
->orWhere('phone', '+' . $phoneFormatted)
->orWhere('phone', $request->phone)
->orWhere('phone', '0' . ltrim($request->phone, '0'));
})
->first();
if (!$admin) {
return back()->withErrors([
'phone' => 'Nomor ini tidak terdaftar sebagai admin.',
])->withInput();
}
$otp = $this->generateAndStoreOtp($admin->email);
$pesan = "🛡️ *Bibit Cabai Bondowoso — Admin*\n\n"
. "Halo {$admin->name},\n\n"
. "Kode verifikasi reset password admin Anda:\n\n"
. "*{$otp}*\n\n"
. "Berlaku selama *3 menit*.\n"
. "Jangan berikan kode ini kepada siapapun.";
$sent = $this->fonnte->sendMessage($phoneFormatted, $pesan);
if (!$sent) {
return back()->with('error', 'Gagal mengirim pesan WhatsApp. Coba lagi.');
}
$request->session()->put('admin_reset_email', $admin->email);
$request->session()->put('admin_reset_method', 'whatsapp');
$request->session()->put('admin_reset_phone_display', '****' . substr($request->phone, -4));
return redirect()->route('admin.password.verify-otp-form')
->with('success', 'Kode OTP dikirim ke WhatsApp admin Anda. Berlaku 3 menit.');
}
private function generateAndStoreOtp(string $email): string
{
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
DB::table('password_reset_tokens')->where('email', $email)->delete();
DB::table('password_reset_tokens')->insert([
'email' => $email,
'token' => Hash::make($otp),
'created_at' => Carbon::now(),
]);
return $otp;
}
// ─────────────────────────────────────────────────────────
// STEP 2 — Tampilkan form input OTP
// ─────────────────────────────────────────────────────────
@ -132,32 +211,38 @@ public function verifyOtp(Request $request)
// STEP 2 — Kirim ulang OTP
// ─────────────────────────────────────────────────────────
public function resendOtp(Request $request)
{
{
$email = $request->session()->get('admin_reset_email');
$method = $request->session()->get('admin_reset_method', 'email');
if (!$email) {
return redirect()->route('admin.password.request')
->with('error', 'Sesi tidak valid. Silakan masukkan email admin kembali.');
->with('error', 'Sesi tidak valid.');
}
$admin = User::where('email', $email)
->where('is_admin', true)
->first();
$admin = User::where('email', $email)->where('is_admin', true)->first();
if (!$admin) {
return redirect()->route('admin.password.request')
->with('error', 'Email tidak ditemukan.');
->with('error', 'Akun admin tidak ditemukan.');
}
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$otp = $this->generateAndStoreOtp($email);
DB::table('password_reset_tokens')->where('email', $email)->delete();
DB::table('password_reset_tokens')->insert([
'email' => $email,
'token' => Hash::make($otp),
'created_at' => Carbon::now(),
]);
if ($method === 'whatsapp') {
$phone = FonnteService::formatPhone(
preg_replace('/^\+/', '', $admin->phone)
);
$pesan = "🛡️ *Bibit Cabai Bondowoso — Admin*\n\n"
. "Halo {$admin->name},\n\n"
. "Kode verifikasi baru Anda:\n\n"
. "*{$otp}*\n\n"
. "Berlaku selama *3 menit*.";
$sent = $this->fonnte->sendMessage($phone, $pesan);
if (!$sent) {
return back()->with('error', 'Gagal mengirim ulang ke WhatsApp.');
}
} else {
Mail::send('emails.admin-otp', [
'otp' => $otp,
'name' => $admin->name ?? 'Admin',
@ -165,10 +250,11 @@ public function resendOtp(Request $request)
$message->to($email)
->subject('Kode OTP Baru (Kirim Ulang) - Bibit Cabai Admin');
});
return back()->with('success', 'Kode OTP baru telah dikirim ke email admin Anda.');
}
return back()->with('success', 'Kode OTP baru telah dikirim.');
}
// ─────────────────────────────────────────────────────────
// STEP 3 — Tampilkan form password baru
// ─────────────────────────────────────────────────────────

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\FonnteService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
@ -13,18 +14,37 @@
class ForgotPasswordController extends Controller
{
// ─────────────────────────────────────────────
// STEP 1 — Show form: enter email
// ─────────────────────────────────────────────
protected FonnteService $fonnte;
public function __construct(FonnteService $fonnte)
{
$this->fonnte = $fonnte;
}
// ──────────────────────────────────────────────
// STEP 1 — Show form: pilih metode (email / WA)
// ──────────────────────────────────────────────
public function showForgotForm()
{
return view('auth.forgot-password');
}
// ─────────────────────────────────────────────
// STEP 1 — Handle: send OTP to email
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// STEP 1 — Handle: kirim OTP (email ATAU WA)
// ─────────────────────────────────────────────
public function sendOtp(Request $request)
{
$method = $request->input('method', 'email'); // 'email' atau 'whatsapp'
if ($method === 'whatsapp') {
return $this->sendOtpViaWhatsapp($request);
}
return $this->sendOtpViaEmail($request);
}
// ── Kirim OTP via Email ──
private function sendOtpViaEmail(Request $request)
{
$request->validate([
'email' => ['required', 'email', 'regex:/@gmail\.com$/'],
@ -35,55 +55,103 @@ public function sendOtp(Request $request)
]);
$user = User::where('email', $request->email)->first();
if (!$user) {
return back()->withErrors(['email' => 'Email tidak ditemukan.'])->withInput();
}
$otp = $this->generateAndStoreOtp($request->email);
Mail::send('emails.otp', ['otp' => $otp, 'name' => $user->name], function ($m) use ($request) {
$m->to($request->email)
->subject('Kode Verifikasi Reset Password - Bibit Cabai Bondowoso');
});
$request->session()->put('reset_email', $request->email);
$request->session()->put('reset_method', 'email');
return redirect()->route('password.verify-otp-form')
->with('success', 'Kode verifikasi dikirim ke email. Berlaku 3 menit.');
}
// ── Kirim OTP via WhatsApp ──
private function sendOtpViaWhatsapp(Request $request)
{
$request->validate([
'phone' => ['required', 'digits_between:10,15'],
], [
'phone.required' => 'Nomor WhatsApp wajib diisi.',
'phone.digits_between' => 'Nomor WhatsApp tidak valid (10-15 digit).',
]);
$phoneFormatted = FonnteService::formatPhone($request->phone);
$user = User::where('phone', $phoneFormatted)
->orWhere('phone', '+' . $phoneFormatted)
->orWhere('phone', $request->phone)
->orWhere('phone', '0' . ltrim($request->phone, '0'))
->first();
if (!$user) {
return back()->withErrors([
'email' => 'Email tidak ditemukan di sistem kami.',
'phone' => 'Nomor WhatsApp tidak ditemukan di sistem kami.',
])->withInput();
}
// Generate 6-digit OTP
// Gunakan email user sebagai key di password_reset_tokens
$otp = $this->generateAndStoreOtp($user->email);
$pesan = "🌶️ *Bibit Cabai Bondowoso*\n\n"
. "Halo {$user->name},\n\n"
. "Kode verifikasi reset password Anda:\n\n"
. "*{$otp}*\n\n"
. "Berlaku selama *3 menit*.\n"
. "Jangan berikan kode ini kepada siapapun.";
$sent = $this->fonnte->sendMessage($phoneFormatted, $pesan);
if (!$sent) {
return back()->with('error', 'Gagal mengirim pesan WhatsApp. Coba lagi.');
}
$request->session()->put('reset_email', $user->email);
$request->session()->put('reset_method', 'whatsapp');
$request->session()->put('reset_phone_display', '****' . substr($request->phone, -4));
return redirect()->route('password.verify-otp-form')
->with('success', 'Kode verifikasi dikirim ke WhatsApp Anda. Berlaku 3 menit.');
}
// ── Helper: generate & simpan OTP ──
private function generateAndStoreOtp(string $email): string
{
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
// Delete any existing token for this email
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
// Store hashed OTP + expiry (10 minutes)
DB::table('password_reset_tokens')->where('email', $email)->delete();
DB::table('password_reset_tokens')->insert([
'email' => $request->email,
'email' => $email,
'token' => Hash::make($otp),
'created_at' => Carbon::now(),
]);
// Send OTP via email
Mail::send('emails.otp', ['otp' => $otp, 'name' => $user->name], function ($message) use ($request) {
$message->to($request->email)
->subject('Kode Verifikasi Reset Password - Bibit Cabai Bondowoso');
});
// Store email in session for next steps
$request->session()->put('reset_email', $request->email);
return redirect()->route('password.verify-otp-form')
->with('success', 'Kode verifikasi telah dikirim ke email Anda. Berlaku selama 10 menit.');
return $otp;
}
// ─────────────────────────────────────────────
// STEP 2 — Show form: enter OTP
// ─────────────────────────────────────────────
// ──────────────────────────────────────────────
// STEP 2 — Show form: verifikasi OTP
// ──────────────────────────────────────────────
public function showVerifyOtpForm(Request $request)
{
if (!$request->session()->has('reset_email')) {
return redirect()->route('password.request')
->with('error', 'Silakan masukkan email Anda terlebih dahulu.');
->with('error', 'Silakan masukkan email atau nomor WA Anda terlebih dahulu.');
}
return view('auth.verify-otp');
}
// ─────────────────────────────────────────────
// STEP 2 — Handle: verify OTP
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// STEP 2 — Handle: verifikasi OTP
// ─────────────────────────────────────────────
public function verifyOtp(Request $request)
{
$request->validate([
@ -94,36 +162,30 @@ public function verifyOtp(Request $request)
]);
$email = $request->session()->get('reset_email');
if (!$email) {
return redirect()->route('password.request')
->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.');
}
$record = DB::table('password_reset_tokens')
->where('email', $email)
->first();
$record = DB::table('password_reset_tokens')->where('email', $email)->first();
if (!$record) {
return back()->with('error', 'Kode OTP tidak ditemukan. Silakan kirim ulang.');
}
// Check expiry: 10 minutes
$createdAt = Carbon::parse($record->created_at);
if (Carbon::now()->diffInMinutes($createdAt) > 10) {
$diffSeconds = Carbon::now()->diffInSeconds($createdAt);
if ($diffSeconds > 180) { // 3 menit = 180 detik
DB::table('password_reset_tokens')->where('email', $email)->delete();
return back()->with('error', 'Kode OTP sudah kadaluarsa. Silakan kirim ulang kode.');
return back()->with('error', 'Kode OTP sudah kadaluarsa. Silakan minta kirim kode lagi.');
}
// Verify OTP
if (!Hash::check($request->otp, $record->token)) {
return back()->with('error', 'Kode OTP salah. Periksa kembali kode yang Anda masukkan.');
return back()->with('error', 'Kode OTP salah. Periksa kembali kode Anda.');
}
// Generate a verified token for the next step
$verifiedToken = Str::random(60);
DB::table('password_reset_tokens')
->where('email', $email)
DB::table('password_reset_tokens')->where('email', $email)
->update(['token' => Hash::make($verifiedToken)]);
$request->session()->put('reset_token', $verifiedToken);
@ -132,57 +194,62 @@ public function verifyOtp(Request $request)
->with('success', 'Kode berhasil diverifikasi! Silakan buat password baru.');
}
// ─────────────────────────────────────────────
// STEP 2 — Handle: resend OTP
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// STEP 2 — Handle: kirim ulang OTP
// ─────────────────────────────────────────────
public function resendOtp(Request $request)
{
$email = $request->session()->get('reset_email');
$method = $request->session()->get('reset_method', 'email');
if (!$email) {
return redirect()->route('password.request')
->with('error', 'Sesi tidak valid. Silakan masukkan email Anda kembali.');
->with('error', 'Sesi tidak valid.');
}
$user = User::where('email', $email)->first();
if (!$user) {
return redirect()->route('password.request')
->with('error', 'Email tidak ditemukan.');
return redirect()->route('password.request')->with('error', 'User tidak ditemukan.');
}
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$otp = $this->generateAndStoreOtp($email);
DB::table('password_reset_tokens')->where('email', $email)->delete();
DB::table('password_reset_tokens')->insert([
'email' => $email,
'token' => Hash::make($otp),
'created_at' => Carbon::now(),
]);
if ($method === 'whatsapp') {
$phone = FonnteService::formatPhone(
preg_replace('/^\+/', '', $user->phone)
);
$pesan = "🌶️ *Bibit Cabai Bondowoso*\n\n"
. "Halo {$user->name},\n\n"
. "Kode verifikasi baru Anda:\n\n"
. "*{$otp}*\n\n"
. "Berlaku selama *3 menit*.";
Mail::send('emails.otp', ['otp' => $otp, 'name' => $user->name], function ($message) use ($email) {
$message->to($email)
->subject('Kode Verifikasi Reset Password (Kirim Ulang) - Bibit Cabai Bondowoso');
$sent = $this->fonnte->sendMessage($phone, $pesan);
if (!$sent) {
return back()->with('error', 'Gagal mengirim ulang ke WhatsApp.');
}
} else {
Mail::send('emails.otp', ['otp' => $otp, 'name' => $user->name], function ($m) use ($email) {
$m->to($email)->subject('Kode Verifikasi (Kirim Ulang) - Bibit Cabai Bondowoso');
});
return back()->with('success', 'Kode OTP baru telah dikirim ke email Anda.');
}
// ─────────────────────────────────────────────
// STEP 3 — Show form: enter new password
// ─────────────────────────────────────────────
return back()->with('success', 'Kode OTP baru telah dikirim.');
}
// ──────────────────────────────────────────────
// STEP 3 — Show & Handle reset password
// (sama seperti kode lama kamu — tidak berubah)
// ──────────────────────────────────────────────
public function showResetForm(Request $request)
{
if (!$request->session()->has('reset_token') || !$request->session()->has('reset_email')) {
return redirect()->route('password.request')
->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.');
}
return view('auth.reset-password');
}
// ─────────────────────────────────────────────
// STEP 3 — Handle: save new password
// ─────────────────────────────────────────────
public function resetPassword(Request $request)
{
$request->validate([
@ -199,27 +266,20 @@ public function resetPassword(Request $request)
$token = $request->session()->get('reset_token');
if (!$email || !$token) {
return redirect()->route('password.request')
->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.');
return redirect()->route('password.request')->with('error', 'Sesi tidak valid.');
}
$record = DB::table('password_reset_tokens')->where('email', $email)->first();
if (!$record || !Hash::check($token, $record->token)) {
return redirect()->route('password.request')
->with('error', 'Token tidak valid. Silakan ulangi dari awal.');
return redirect()->route('password.request')->with('error', 'Token tidak valid.');
}
// Update password
User::where('email', $email)->update([
'password' => Hash::make($request->password),
]);
User::where('email', $email)->update(['password' => Hash::make($request->password)]);
// Clean up
DB::table('password_reset_tokens')->where('email', $email)->delete();
$request->session()->forget(['reset_email', 'reset_token']);
$request->session()->forget(['reset_email', 'reset_token', 'reset_method', 'reset_phone_display']);
return redirect()->route('login')
->with('success', 'Password berhasil diubah! Silakan login dengan password baru Anda.');
->with('success', 'Password berhasil diubah! Silakan login.');
}
}

View File

@ -37,6 +37,7 @@ public function register(Request $request): RedirectResponse
'email.regex' => 'Email harus menggunakan domain @gmail.com dan tidak boleh mengandung spasi.',
'phone.required' => 'Nomor telepon wajib diisi.',
'phone.regex' => 'Nomor telepon harus diawali dengan +62 dan hanya boleh berisi angka.',
'phone.unique' => 'Nomor telepon sudah terdaftar di sistem kami.',
// 'phone.min' => 'Nomor telepon minimal harus 12 karakter.',
'address.required' => 'Alamat wajib diisi.',
'address.min' => 'Alamat minimal harus 12 karakter.',
@ -49,11 +50,24 @@ public function register(Request $request): RedirectResponse
]);
// Validasi password familiar
$validator->after(function ($validator) use ($request) {
if ($request->has('password') && $this->isCommonPassword($request->password)) {
$validator->errors()->add('password', 'Password yang Anda gunakan terlalu umum dan sering digunakan. Silakan pilih password yang lebih unik untuk keamanan akun Anda.');
// Validasi password familiar & pernah dipakai user lain
$validator->after(function ($validator) use ($request) {
if ($request->has('password')) {
if ($this->isCommonPassword($request->password)) {
$validator->errors()->add('password', 'Password yang Anda gunakan terlalu umum. Silakan pilih password yang lebih unik.');
}
});
if ($this->isPasswordAlreadyUsed($request->password)) {
$validator->errors()->add('password', 'Password ini sudah pernah digunakan oleh akun lain. Silakan gunakan password yang berbeda.');
}
}
if ($request->has('phone') && $request->phone) {
if ($this->isPhoneAlreadyUsed($request->phone)) {
$validator->errors()->add('phone', 'Nomor telepon sudah terdaftar di sistem kami.');
}
}
});
if ($validator->fails()) {
return redirect()->back()->withErrors($validator)->withInput();
@ -124,4 +138,37 @@ private function isCommonPassword(string $password): bool
return in_array(strtolower($password), array_map('strtolower', $commonPasswords));
}
/**
* Cek apakah password sudah pernah dipakai oleh user lain di database
*/
private function isPasswordAlreadyUsed(string $password): bool
{
$users = User::whereNotNull('password')->get(['password']);
foreach ($users as $user) {
if (Hash::check($password, $user->password)) {
return true;
}
}
return false;
}
/**
* Cek nomor telepon sudah terdaftar dalam berbagai format
*/
private function isPhoneAlreadyUsed(string $phone): bool
{
$digitsOnly = preg_replace('/\D/', '', $phone);
$formats = [
$phone, // +6281234567890
$digitsOnly, // 6281234567890
'+' . $digitsOnly, // +6281234567890
'0' . substr($digitsOnly, 2), // 081234567890
];
return User::whereIn('phone', $formats)->exists();
}
}

View File

@ -3,21 +3,67 @@
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FonnteService
{
public function sendOTP($nomor, $otp)
{
// Format nomor: 08xxx → 628xxx
$nomor = '62' . ltrim($nomor, '0');
protected string $token;
protected string $apiUrl = 'https://api.fonnte.com/send';
public function __construct()
{
$this->token = config('services.fonnte.token');
}
/**
* Kirim pesan WhatsApp via Fonnte
*
* @param string $phone Nomor WA tujuan (format: 628xxx)
* @param string $message Isi pesan
* @return bool
*/
public function sendMessage(string $phone, string $message): bool
{
try {
$response = Http::withHeaders([
'Authorization' => env('FONNTE_TOKEN'),
])->post('https://api.fonnte.com/send', [
'target' => $nomor,
'message' => "Kode OTP reset password Anda: *$otp*\n\nBerlaku selama 10 menit. Jangan berikan kode ini kepada siapapun.",
'Authorization' => $this->token,
])->post($this->apiUrl, [
'target' => $phone,
'message' => $message,
'countryCode' => '62', // Indonesia
]);
return $response->json();
$body = $response->json();
if ($response->successful() && isset($body['status']) && $body['status'] === true) {
return true;
}
Log::error('Fonnte send failed', ['response' => $body]);
return false;
} catch (\Exception $e) {
Log::error('Fonnte exception: ' . $e->getMessage());
return false;
}
}
/**
* Format nomor WA ke format internasional
* Misal: 08123 628123, +628123 628123
*/
public static function formatPhone(string $phone): string
{
$phone = preg_replace('/[^\d]/', '', $phone); // hapus +, strip, spasi, dll
if (str_starts_with($phone, '0')) {
$phone = '62' . substr($phone, 1);
} elseif (str_starts_with($phone, '62')) {
// sudah benar
} else {
$phone = '62' . $phone;
}
return $phone;
}
}

View File

@ -35,4 +35,8 @@
],
],
'fonnte' => [
'token' => env('FONNTE_TOKEN'),
],
];

View File

@ -95,7 +95,7 @@
<div class="text-center mb-3">
<h4 class="fw-bold text-dark mb-1">Lupa Password Admin</h4>
<p class="text-muted small">Masukkan email admin yang terdaftar</p>
<p class="text-muted small">Gunakan email atau WhatsApp admin terdaftar</p>
</div>
{{-- Step Indicator --}}
@ -136,9 +136,30 @@
@endif
{{-- Form --}}
<form method="POST" action="{{ route('admin.password.email') }}" id="forgotForm" novalidate>
@csrf
{{-- Toggle Metode --}}
<div class="d-flex gap-2 mb-4">
<button type="button"
class="btn btn-sm flex-fill fw-semibold"
id="btnEmail"
style="background:#11998e;color:white;border-radius:20px;"
onclick="switchMethod('email')">
📧 Email
</button>
<button type="button"
class="btn btn-sm flex-fill fw-semibold"
id="btnWa"
style="background:white;color:#11998e;border:2px solid #11998e;border-radius:20px;"
onclick="switchMethod('whatsapp')">
💬 WhatsApp
</button>
</div>
<form method="POST" action="{{ route('admin.password.email') }}" id="forgotForm" novalidate>
@csrf
<input type="hidden" name="method" id="methodInput" value="email">
{{-- EMAIL SECTION --}}
<div id="emailSection">
<div class="mb-4">
<label for="email" class="form-label fw-semibold">Email Admin</label>
<input type="email"
@ -147,8 +168,7 @@ class="form-control @error('email') is-invalid @enderror"
name="email"
value="{{ old('email') }}"
placeholder="admin@example.com"
autofocus
required>
autofocus>
<div class="form-text text-muted small">
Hanya akun admin yang terdaftar di sistem
</div>
@ -159,6 +179,33 @@ class="form-control @error('email') is-invalid @enderror"
<small>⚠️ Format email tidak valid</small>
</div>
</div>
</div>
{{-- WHATSAPP SECTION --}}
<div id="waSection" style="display:none;">
<div class="mb-4">
<label for="phone" class="form-label fw-semibold">Nomor WhatsApp Admin</label>
<div class="input-group">
<span class="input-group-text fw-semibold" style="color:#11998e;">+62</span>
<input type="tel"
class="form-control @error('phone') is-invalid @enderror"
id="phone"
name="phone"
value="{{ old('phone') }}"
placeholder="8123456789"
inputmode="numeric">
</div>
<div class="form-text text-muted small">
Masukkan nomor tanpa angka 0 di depan
</div>
@error('phone')
<div class="text-danger mt-1 small">{{ $message }}</div>
@enderror
<div id="phoneError" class="text-danger mt-1" style="display:none;">
<small>⚠️ Nomor WhatsApp tidak valid (min 9 digit)</small>
</div>
</div>
</div>
<div class="d-grid mb-3">
<button type="submit" class="btn btn-primary-custom py-2 fw-bold" id="submitBtn" disabled>
@ -181,31 +228,75 @@ class="form-control @error('email') is-invalid @enderror"
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const emailInput = document.getElementById('email');
const submitBtn = document.getElementById('submitBtn');
const emailError = document.getElementById('emailError');
let currentMethod = 'email';
function validateEmail(val) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) && !/\s/.test(val);
}
function switchMethod(method) {
currentMethod = method;
document.getElementById('methodInput').value = method;
emailInput.addEventListener('input', function () {
const val = this.value.trim();
if (val.length > 0 && !validateEmail(val)) {
emailError.style.display = 'block';
this.classList.add('is-invalid');
submitBtn.disabled = true;
const btnEmail = document.getElementById('btnEmail');
const btnWa = document.getElementById('btnWa');
const emailSec = document.getElementById('emailSection');
const waSec = document.getElementById('waSection');
if (method === 'email') {
btnEmail.style.background = '#11998e';
btnEmail.style.color = 'white';
btnEmail.style.border = 'none';
btnWa.style.background = 'white';
btnWa.style.color = '#11998e';
btnWa.style.border = '2px solid #11998e';
emailSec.style.display = 'block';
waSec.style.display = 'none';
} else {
emailError.style.display = 'none';
this.classList.remove('is-invalid');
submitBtn.disabled = val.length === 0;
btnWa.style.background = '#11998e';
btnWa.style.color = 'white';
btnWa.style.border = 'none';
btnEmail.style.background = 'white';
btnEmail.style.color = '#11998e';
btnEmail.style.border = '2px solid #11998e';
emailSec.style.display = 'none';
waSec.style.display = 'block';
}
document.getElementById('submitBtn').disabled = true;
validateInput();
}
function validateInput() {
const submitBtn = document.getElementById('submitBtn');
if (currentMethod === 'email') {
const val = document.getElementById('email').value.trim();
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val);
document.getElementById('emailError').style.display =
(val.length > 0 && !valid) ? 'block' : 'none';
submitBtn.disabled = !valid;
} else {
const val = document.getElementById('phone').value.replace(/\D/g, '');
const valid = val.length >= 9 && val.length <= 13;
document.getElementById('phoneError').style.display =
(val.length > 0 && !valid) ? 'block' : 'none';
submitBtn.disabled = !valid;
}
}
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('email').addEventListener('input', function () {
this.value = this.value.replace(/\s/g, '');
validateInput();
});
emailInput.addEventListener('keydown', function (e) {
document.getElementById('phone').addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '');
validateInput();
});
['email', 'phone'].forEach(id => {
document.getElementById(id).addEventListener('keydown', function (e) {
if (e.key === ' ') e.preventDefault();
});
});
});
</script>
</body>

View File

@ -20,7 +20,7 @@
<div class="d-flex align-items-center justify-content-center mb-4">
<div class="step-item active">
<div class="step-circle bg-success text-white">1</div>
<div class="step-label text-success fw-bold">Email</div>
<div class="step-label text-success fw-bold">Identitas</div>
</div>
<div class="step-line bg-secondary mx-2"></div>
<div class="step-item">
@ -35,20 +35,32 @@
</div>
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<div class="alert alert-danger alert-dismissible fade show">
<i class="fas fa-exclamation-circle me-2"></i>{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
@endif
<p class="text-muted text-center mb-4">
<i class="fas fa-info-circle text-success me-1"></i>
Kami akan mengirim kode verifikasi ke email Anda untuk mereset password.
</p>
{{-- Toggle Metode --}}
<div class="d-flex gap-2 mb-4" id="methodToggle">
<button type="button" class="btn btn-success flex-fill" id="btnEmail" onclick="switchMethod('email')">
<i class="fas fa-envelope me-1"></i> Gunakan Email
</button>
<button type="button" class="btn btn-outline-success flex-fill" id="btnWa" onclick="switchMethod('whatsapp')">
<i class="fab fa-whatsapp me-1"></i> Gunakan WhatsApp
</button>
</div>
<form method="POST" action="{{ route('password.email') }}" id="forgotForm">
@csrf
<input type="hidden" name="method" id="methodInput" value="email">
{{-- Email Section --}}
<div id="emailSection">
<p class="text-muted text-center mb-3 small">
<i class="fas fa-info-circle text-success me-1"></i>
Kode verifikasi akan dikirim ke email Anda.
</p>
<div class="mb-4">
<label for="email" class="form-label fw-semibold">
Email <span class="text-danger">*</span>
@ -59,11 +71,8 @@
</span>
<input type="email"
class="form-control @error('email') is-invalid @enderror"
id="email"
name="email"
id="email" name="email"
value="{{ old('email') }}"
required
autofocus
placeholder="contoh@gmail.com">
</div>
<div class="form-text text-muted">
@ -72,31 +81,58 @@ class="form-control @error('email') is-invalid @enderror"
@error('email')
<div class="text-danger mt-1"><small>{{ $message }}</small></div>
@enderror
<div id="emailError" class="text-danger mt-1" style="display: none;">
<div id="emailError" class="text-danger mt-1" style="display:none;">
<small>Email harus menggunakan domain @gmail.com</small>
</div>
</div>
</div>
{{-- WhatsApp Section --}}
<div id="waSection" style="display:none;">
<p class="text-muted text-center mb-3 small">
<i class="fab fa-whatsapp text-success me-1"></i>
Kode verifikasi akan dikirim via WhatsApp.
</p>
<div class="mb-4">
<label for="phone" class="form-label fw-semibold">
Nomor WhatsApp <span class="text-danger">*</span>
</label>
<div class="input-group">
<span class="input-group-text bg-light fw-semibold text-success">+62</span>
<input type="tel"
class="form-control @error('phone') is-invalid @enderror"
id="phone" name="phone"
value="{{ old('phone') }}"
placeholder="8123456789"
inputmode="numeric">
</div>
<div class="form-text text-muted">
<small><i class="fas fa-info-circle"></i> Masukkan nomor tanpa angka 0 di depan</small>
</div>
@error('phone')
<div class="text-danger mt-1"><small>{{ $message }}</small></div>
@enderror
<div id="phoneError" class="text-danger mt-1" style="display:none;">
<small>Nomor WhatsApp tidak valid</small>
</div>
</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
<i class="fas fa-paper-plane me-2"></i>Kirim Kode Verifikasi
<i class="fas fa-paper-plane me-2"></i>
<span id="submitText">Kirim Kode Verifikasi</span>
</button>
</div>
</form>
<hr class="my-4">
<div class="text-center">
<a href="{{ route('login') }}" class="text-success text-decoration-none">
<i class="fas fa-arrow-left me-1"></i>Kembali ke Halaman Login
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.step-item {
display: flex;
@ -126,28 +162,57 @@ class="form-control @error('email') is-invalid @enderror"
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
const emailInput = document.getElementById('email');
let currentMethod = 'email';
function switchMethod(method) {
currentMethod = method;
document.getElementById('methodInput').value = method;
const btnEmail = document.getElementById('btnEmail');
const btnWa = document.getElementById('btnWa');
const emailSection = document.getElementById('emailSection');
const waSection = document.getElementById('waSection');
const submitBtn = document.getElementById('submitBtn');
const emailError = document.getElementById('emailError');
emailInput.addEventListener('input', function () {
const val = this.value;
const gmailPattern = /@gmail\.com$/;
const hasSpaces = /\s/.test(val);
if (val.length > 0 && (!gmailPattern.test(val) || hasSpaces)) {
emailError.style.display = 'block';
this.classList.add('is-invalid');
submitBtn.disabled = true;
if (method === 'email') {
btnEmail.className = 'btn btn-success flex-fill';
btnWa.className = 'btn btn-outline-success flex-fill';
emailSection.style.display = 'block';
waSection.style.display = 'none';
} else {
emailError.style.display = 'none';
this.classList.remove('is-invalid');
submitBtn.disabled = val.length === 0;
btnEmail.className = 'btn btn-outline-success flex-fill';
btnWa.className = 'btn btn-success flex-fill';
emailSection.style.display = 'none';
waSection.style.display = 'block';
}
});
emailInput.addEventListener('keydown', function (e) {
submitBtn.disabled = true;
validateInput();
}
function validateInput() {
const submitBtn = document.getElementById('submitBtn');
if (currentMethod === 'email') {
const val = document.getElementById('email').value;
const valid = /^[^\s]+@gmail\.com$/.test(val);
document.getElementById('emailError').style.display = (val.length > 0 && !valid) ? 'block' : 'none';
submitBtn.disabled = !valid;
} else {
const val = document.getElementById('phone').value.replace(/\D/g, '');
const valid = val.length >= 9 && val.length <= 13;
document.getElementById('phoneError').style.display = (val.length > 0 && !valid) ? 'block' : 'none';
submitBtn.disabled = !valid;
}
}
document.addEventListener('DOMContentLoaded', function () {
document.getElementById('email').addEventListener('input', validateInput);
document.getElementById('phone').addEventListener('input', function () {
this.value = this.value.replace(/\D/g, '');
validateInput();
});
document.getElementById('phone').addEventListener('keydown', function (e) {
if (e.key === ' ') e.preventDefault();
});
});

View File

@ -50,14 +50,22 @@
</div>
@endif
{{-- Info email target --}}
{{-- Info target pengiriman --}}
<div class="alert alert-light border-start border-success border-3 mb-4">
<div class="d-flex align-items-center">
@if(session('reset_method') === 'whatsapp')
<i class="fab fa-whatsapp text-success me-3 fs-5"></i>
<div>
<div class="fw-semibold text-dark">Kode dikirim via WhatsApp ke:</div>
<div class="text-muted small">{{ session('reset_phone_display', 'nomor WA Anda') }}</div>
</div>
@else
<i class="fas fa-envelope text-success me-3 fs-5"></i>
<div>
<div class="fw-semibold text-dark">Kode dikirim ke:</div>
<div class="text-muted small">{{ session('reset_email', 'email Anda') }}</div>
</div>
@endif
</div>
</div>
@ -97,12 +105,13 @@
<div class="text-center mb-4">
<div id="timerWrapper">
<p class="text-muted small mb-1">Kode berlaku selama:</p>
<span id="countdown" class="badge bg-success fs-6 px-3 py-2">10:00</span>
<span id="countdown" class="badge bg-success fs-6 px-3 py-2">03:00</span>
</div>
<div id="expiredWrapper" style="display:none;">
<span class="badge bg-danger fs-6 px-3 py-2">
<i class="fas fa-times-circle me-1"></i>Kode sudah kadaluarsa
</span>
<div class="alert alert-danger text-center py-2 px-3 mb-0">
<i class="fas fa-times-circle me-1"></i>
<strong>Kode sudah kadaluarsa.</strong><br>
<small>Silakan minta kirim kode lagi.</small>
</div>
</div>
@ -190,6 +199,8 @@
const timerWrapper = document.getElementById('timerWrapper');
const expiredWrapper = document.getElementById('expiredWrapper');
let isExpired = false; // ← flag global
// ---- OTP box logic ----
boxes.forEach((box, index) => {
box.addEventListener('input', function () {
@ -212,7 +223,6 @@
}
});
// Handle paste
box.addEventListener('paste', function (e) {
e.preventDefault();
const pasted = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
@ -231,11 +241,12 @@
function updateHidden() {
const otp = Array.from(boxes).map(b => b.value).join('');
otpHidden.value = otp;
submitBtn.disabled = otp.length !== 6;
// Tombol submit aktif HANYA jika 6 digit DAN belum expired
submitBtn.disabled = (otp.length !== 6 || isExpired);
}
// ---- Countdown Timer: 10 minutes ----
let seconds = 10 * 60;
// ---- Countdown Timer: 3 menit ----
let seconds = 3 * 60;
function formatTime(s) {
const m = Math.floor(s / 60).toString().padStart(2, '0');
@ -247,22 +258,38 @@ function formatTime(s) {
seconds--;
countdownEl.textContent = formatTime(seconds);
// Berubah merah saat 60 detik terakhir
if (seconds <= 60) {
countdownEl.classList.replace('bg-success', 'bg-warning');
countdownEl.classList.add('text-dark');
countdownEl.classList.remove('bg-success', 'bg-warning');
countdownEl.classList.add('bg-danger');
}
if (seconds <= 0) {
clearInterval(timer);
// Set flag expired
isExpired = true;
// Sembunyikan timer, tampilkan pesan expired
timerWrapper.style.display = 'none';
expiredWrapper.style.display = 'block';
// Nonaktifkan submit & semua kotak OTP
submitBtn.disabled = true;
// Enable resend after expiry
boxes.forEach(box => {
box.disabled = true;
box.classList.add('bg-light');
});
// Aktifkan tombol kirim ulang
resendBtn.disabled = false;
}
}, 1000);
// Enable resend after 60s
setTimeout(() => { resendBtn.disabled = false; }, 60000);
// Aktifkan resend setelah 30 detik
setTimeout(() => {
if (!isExpired) resendBtn.disabled = false;
}, 30000);
});
</script>
@endsection