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\Facades\Mail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Carbon\Carbon; use Carbon\Carbon;
use App\Services\FonnteService;
class AdminForgotPasswordController extends Controller class AdminForgotPasswordController extends Controller
{ {
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
// STEP 1 — Tampilkan form input email // STEP 1 — Tampilkan form input email
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
protected FonnteService $fonnte;
public function __construct(FonnteService $fonnte)
{
$this->fonnte = $fonnte;
}
public function showForgotForm() public function showForgotForm()
{ {
return view('admin.forgot-password'); return view('admin.forgot-password');
} }
@ -25,7 +35,18 @@ public function showForgotForm()
// STEP 1 — Proses: validasi email & kirim OTP // STEP 1 — Proses: validasi email & kirim OTP
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
public function sendOtp(Request $request) 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([ $request->validate([
'email' => ['required', 'email'], 'email' => ['required', 'email'],
], [ ], [
@ -43,14 +64,7 @@ public function sendOtp(Request $request)
])->withInput(); ])->withInput();
} }
$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); $otp = $this->generateAndStoreOtp($request->email);
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(),
]);
Mail::send('emails.admin-otp', [ Mail::send('emails.admin-otp', [
'otp' => $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_email', $request->email);
$request->session()->put('admin_reset_method', 'email');
return redirect()->route('admin.password.verify-otp-form') 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 // STEP 2 — Tampilkan form input OTP
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
@ -132,32 +211,38 @@ public function verifyOtp(Request $request)
// STEP 2 — Kirim ulang OTP // STEP 2 — Kirim ulang OTP
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────
public function resendOtp(Request $request) public function resendOtp(Request $request)
{ {
$email = $request->session()->get('admin_reset_email'); $email = $request->session()->get('admin_reset_email');
$method = $request->session()->get('admin_reset_method', 'email');
if (!$email) { if (!$email) {
return redirect()->route('admin.password.request') 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) $admin = User::where('email', $email)->where('is_admin', true)->first();
->where('is_admin', true)
->first();
if (!$admin) { if (!$admin) {
return redirect()->route('admin.password.request') 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(); if ($method === 'whatsapp') {
DB::table('password_reset_tokens')->insert([ $phone = FonnteService::formatPhone(
'email' => $email, preg_replace('/^\+/', '', $admin->phone)
'token' => Hash::make($otp), );
'created_at' => Carbon::now(), $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', [ Mail::send('emails.admin-otp', [
'otp' => $otp, 'otp' => $otp,
'name' => $admin->name ?? 'Admin', 'name' => $admin->name ?? 'Admin',
@ -165,10 +250,11 @@ public function resendOtp(Request $request)
$message->to($email) $message->to($email)
->subject('Kode OTP Baru (Kirim Ulang) - Bibit Cabai Admin'); ->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 // STEP 3 — Tampilkan form password baru
// ───────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\FonnteService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -13,18 +14,37 @@
class ForgotPasswordController extends Controller class ForgotPasswordController extends Controller
{ {
// ───────────────────────────────────────────── protected FonnteService $fonnte;
// STEP 1 — Show form: enter email
// ───────────────────────────────────────────── public function __construct(FonnteService $fonnte)
{
$this->fonnte = $fonnte;
}
// ──────────────────────────────────────────────
// STEP 1 — Show form: pilih metode (email / WA)
// ──────────────────────────────────────────────
public function showForgotForm() public function showForgotForm()
{ {
return view('auth.forgot-password'); 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) 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([ $request->validate([
'email' => ['required', 'email', 'regex:/@gmail\.com$/'], 'email' => ['required', 'email', 'regex:/@gmail\.com$/'],
@ -35,55 +55,103 @@ public function sendOtp(Request $request)
]); ]);
$user = User::where('email', $request->email)->first(); $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) { if (!$user) {
return back()->withErrors([ return back()->withErrors([
'email' => 'Email tidak ditemukan di sistem kami.', 'phone' => 'Nomor WhatsApp tidak ditemukan di sistem kami.',
])->withInput(); ])->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); $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', $email)->delete();
DB::table('password_reset_tokens')->where('email', $request->email)->delete();
// Store hashed OTP + expiry (10 minutes)
DB::table('password_reset_tokens')->insert([ DB::table('password_reset_tokens')->insert([
'email' => $request->email, 'email' => $email,
'token' => Hash::make($otp), 'token' => Hash::make($otp),
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
]); ]);
// Send OTP via email return $otp;
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.');
} }
// ───────────────────────────────────────────── // ──────────────────────────────────────────────
// STEP 2 — Show form: enter OTP // STEP 2 — Show form: verifikasi OTP
// ───────────────────────────────────────────── // ──────────────────────────────────────────────
public function showVerifyOtpForm(Request $request) public function showVerifyOtpForm(Request $request)
{ {
if (!$request->session()->has('reset_email')) { if (!$request->session()->has('reset_email')) {
return redirect()->route('password.request') 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'); return view('auth.verify-otp');
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// STEP 2 — Handle: verify OTP // STEP 2 — Handle: verifikasi OTP
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
public function verifyOtp(Request $request) public function verifyOtp(Request $request)
{ {
$request->validate([ $request->validate([
@ -94,36 +162,30 @@ public function verifyOtp(Request $request)
]); ]);
$email = $request->session()->get('reset_email'); $email = $request->session()->get('reset_email');
if (!$email) { if (!$email) {
return redirect()->route('password.request') return redirect()->route('password.request')
->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.'); ->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.');
} }
$record = DB::table('password_reset_tokens') $record = DB::table('password_reset_tokens')->where('email', $email)->first();
->where('email', $email)
->first();
if (!$record) { if (!$record) {
return back()->with('error', 'Kode OTP tidak ditemukan. Silakan kirim ulang.'); return back()->with('error', 'Kode OTP tidak ditemukan. Silakan kirim ulang.');
} }
// Check expiry: 10 minutes
$createdAt = Carbon::parse($record->created_at); $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(); 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)) { 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); $verifiedToken = Str::random(60);
DB::table('password_reset_tokens') DB::table('password_reset_tokens')->where('email', $email)
->where('email', $email)
->update(['token' => Hash::make($verifiedToken)]); ->update(['token' => Hash::make($verifiedToken)]);
$request->session()->put('reset_token', $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.'); ->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) public function resendOtp(Request $request)
{ {
$email = $request->session()->get('reset_email'); $email = $request->session()->get('reset_email');
$method = $request->session()->get('reset_method', 'email');
if (!$email) { if (!$email) {
return redirect()->route('password.request') 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(); $user = User::where('email', $email)->first();
if (!$user) { if (!$user) {
return redirect()->route('password.request') return redirect()->route('password.request')->with('error', 'User tidak ditemukan.');
->with('error', 'Email 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(); if ($method === 'whatsapp') {
DB::table('password_reset_tokens')->insert([ $phone = FonnteService::formatPhone(
'email' => $email, preg_replace('/^\+/', '', $user->phone)
'token' => Hash::make($otp), );
'created_at' => Carbon::now(), $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) { $sent = $this->fonnte->sendMessage($phone, $pesan);
$message->to($email) if (!$sent) {
->subject('Kode Verifikasi Reset Password (Kirim Ulang) - Bibit Cabai Bondowoso'); 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.');
} }
// ───────────────────────────────────────────── return back()->with('success', 'Kode OTP baru telah dikirim.');
// STEP 3 — Show form: enter new password }
// ─────────────────────────────────────────────
// ──────────────────────────────────────────────
// STEP 3 — Show & Handle reset password
// (sama seperti kode lama kamu — tidak berubah)
// ──────────────────────────────────────────────
public function showResetForm(Request $request) public function showResetForm(Request $request)
{ {
if (!$request->session()->has('reset_token') || !$request->session()->has('reset_email')) { if (!$request->session()->has('reset_token') || !$request->session()->has('reset_email')) {
return redirect()->route('password.request') return redirect()->route('password.request')
->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.'); ->with('error', 'Sesi tidak valid. Silakan ulangi dari awal.');
} }
return view('auth.reset-password'); return view('auth.reset-password');
} }
// ─────────────────────────────────────────────
// STEP 3 — Handle: save new password
// ─────────────────────────────────────────────
public function resetPassword(Request $request) public function resetPassword(Request $request)
{ {
$request->validate([ $request->validate([
@ -199,27 +266,20 @@ public function resetPassword(Request $request)
$token = $request->session()->get('reset_token'); $token = $request->session()->get('reset_token');
if (!$email || !$token) { if (!$email || !$token) {
return redirect()->route('password.request') return redirect()->route('password.request')->with('error', 'Sesi tidak valid.');
->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 || !Hash::check($token, $record->token)) { if (!$record || !Hash::check($token, $record->token)) {
return redirect()->route('password.request') return redirect()->route('password.request')->with('error', 'Token tidak valid.');
->with('error', 'Token tidak valid. Silakan ulangi dari awal.');
} }
// 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(); 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') 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.', 'email.regex' => 'Email harus menggunakan domain @gmail.com dan tidak boleh mengandung spasi.',
'phone.required' => 'Nomor telepon wajib diisi.', 'phone.required' => 'Nomor telepon wajib diisi.',
'phone.regex' => 'Nomor telepon harus diawali dengan +62 dan hanya boleh berisi angka.', '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.', // 'phone.min' => 'Nomor telepon minimal harus 12 karakter.',
'address.required' => 'Alamat wajib diisi.', 'address.required' => 'Alamat wajib diisi.',
'address.min' => 'Alamat minimal harus 12 karakter.', 'address.min' => 'Alamat minimal harus 12 karakter.',
@ -49,11 +50,24 @@ public function register(Request $request): RedirectResponse
]); ]);
// Validasi password familiar // Validasi password familiar
$validator->after(function ($validator) use ($request) { // Validasi password familiar & pernah dipakai user lain
if ($request->has('password') && $this->isCommonPassword($request->password)) { $validator->after(function ($validator) use ($request) {
$validator->errors()->add('password', 'Password yang Anda gunakan terlalu umum dan sering digunakan. Silakan pilih password yang lebih unik untuk keamanan akun Anda.'); 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()) { if ($validator->fails()) {
return redirect()->back()->withErrors($validator)->withInput(); 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)); 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; namespace App\Services;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FonnteService class FonnteService
{ {
public function sendOTP($nomor, $otp) protected string $token;
{ protected string $apiUrl = 'https://api.fonnte.com/send';
// Format nomor: 08xxx → 628xxx
$nomor = '62' . ltrim($nomor, '0');
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([ $response = Http::withHeaders([
'Authorization' => env('FONNTE_TOKEN'), 'Authorization' => $this->token,
])->post('https://api.fonnte.com/send', [ ])->post($this->apiUrl, [
'target' => $nomor, 'target' => $phone,
'message' => "Kode OTP reset password Anda: *$otp*\n\nBerlaku selama 10 menit. Jangan berikan kode ini kepada siapapun.", '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"> <div class="text-center mb-3">
<h4 class="fw-bold text-dark mb-1">Lupa Password Admin</h4> <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> </div>
{{-- Step Indicator --}} {{-- Step Indicator --}}
@ -136,9 +136,30 @@
@endif @endif
{{-- Form --}} {{-- Form --}}
<form method="POST" action="{{ route('admin.password.email') }}" id="forgotForm" novalidate> {{-- Toggle Metode --}}
@csrf <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"> <div class="mb-4">
<label for="email" class="form-label fw-semibold">Email Admin</label> <label for="email" class="form-label fw-semibold">Email Admin</label>
<input type="email" <input type="email"
@ -147,8 +168,7 @@ class="form-control @error('email') is-invalid @enderror"
name="email" name="email"
value="{{ old('email') }}" value="{{ old('email') }}"
placeholder="admin@example.com" placeholder="admin@example.com"
autofocus autofocus>
required>
<div class="form-text text-muted small"> <div class="form-text text-muted small">
Hanya akun admin yang terdaftar di sistem Hanya akun admin yang terdaftar di sistem
</div> </div>
@ -159,6 +179,33 @@ class="form-control @error('email') is-invalid @enderror"
<small>⚠️ Format email tidak valid</small> <small>⚠️ Format email tidak valid</small>
</div> </div>
</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"> <div class="d-grid mb-3">
<button type="submit" class="btn btn-primary-custom py-2 fw-bold" id="submitBtn" disabled> <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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { let currentMethod = 'email';
const emailInput = document.getElementById('email');
const submitBtn = document.getElementById('submitBtn');
const emailError = document.getElementById('emailError');
function validateEmail(val) { function switchMethod(method) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) && !/\s/.test(val); currentMethod = method;
} document.getElementById('methodInput').value = method;
emailInput.addEventListener('input', function () { const btnEmail = document.getElementById('btnEmail');
const val = this.value.trim(); const btnWa = document.getElementById('btnWa');
if (val.length > 0 && !validateEmail(val)) { const emailSec = document.getElementById('emailSection');
emailError.style.display = 'block'; const waSec = document.getElementById('waSection');
this.classList.add('is-invalid');
submitBtn.disabled = true; 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 { } else {
emailError.style.display = 'none'; btnWa.style.background = '#11998e';
this.classList.remove('is-invalid'); btnWa.style.color = 'white';
submitBtn.disabled = val.length === 0; 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(); if (e.key === ' ') e.preventDefault();
}); });
});
}); });
</script> </script>
</body> </body>

View File

@ -20,7 +20,7 @@
<div class="d-flex align-items-center justify-content-center mb-4"> <div class="d-flex align-items-center justify-content-center mb-4">
<div class="step-item active"> <div class="step-item active">
<div class="step-circle bg-success text-white">1</div> <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>
<div class="step-line bg-secondary mx-2"></div> <div class="step-line bg-secondary mx-2"></div>
<div class="step-item"> <div class="step-item">
@ -35,20 +35,32 @@
</div> </div>
@if(session('error')) @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') }} <i class="fas fa-exclamation-circle me-2"></i>{{ session('error') }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div> </div>
@endif @endif
<p class="text-muted text-center mb-4"> {{-- Toggle Metode --}}
<i class="fas fa-info-circle text-success me-1"></i> <div class="d-flex gap-2 mb-4" id="methodToggle">
Kami akan mengirim kode verifikasi ke email Anda untuk mereset password. <button type="button" class="btn btn-success flex-fill" id="btnEmail" onclick="switchMethod('email')">
</p> <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"> <form method="POST" action="{{ route('password.email') }}" id="forgotForm">
@csrf @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"> <div class="mb-4">
<label for="email" class="form-label fw-semibold"> <label for="email" class="form-label fw-semibold">
Email <span class="text-danger">*</span> Email <span class="text-danger">*</span>
@ -59,11 +71,8 @@
</span> </span>
<input type="email" <input type="email"
class="form-control @error('email') is-invalid @enderror" class="form-control @error('email') is-invalid @enderror"
id="email" id="email" name="email"
name="email"
value="{{ old('email') }}" value="{{ old('email') }}"
required
autofocus
placeholder="contoh@gmail.com"> placeholder="contoh@gmail.com">
</div> </div>
<div class="form-text text-muted"> <div class="form-text text-muted">
@ -72,31 +81,58 @@ class="form-control @error('email') is-invalid @enderror"
@error('email') @error('email')
<div class="text-danger mt-1"><small>{{ $message }}</small></div> <div class="text-danger mt-1"><small>{{ $message }}</small></div>
@enderror @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> <small>Email harus menggunakan domain @gmail.com</small>
</div> </div>
</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"> <div class="d-grid">
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled> <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> </button>
</div> </div>
</form> </form>
<hr class="my-4"> <hr class="my-4">
<div class="text-center"> <div class="text-center">
<a href="{{ route('login') }}" class="text-success text-decoration-none"> <a href="{{ route('login') }}" class="text-success text-decoration-none">
<i class="fas fa-arrow-left me-1"></i>Kembali ke Halaman Login <i class="fas fa-arrow-left me-1"></i>Kembali ke Halaman Login
</a> </a>
</div> </div>
</div>
</div>
</div>
</div>
</div> </div>
<style> <style>
.step-item { .step-item {
display: flex; display: flex;
@ -126,28 +162,57 @@ class="form-control @error('email') is-invalid @enderror"
</style> </style>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { let currentMethod = 'email';
const emailInput = document.getElementById('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 submitBtn = document.getElementById('submitBtn');
const emailError = document.getElementById('emailError');
emailInput.addEventListener('input', function () { if (method === 'email') {
const val = this.value; btnEmail.className = 'btn btn-success flex-fill';
const gmailPattern = /@gmail\.com$/; btnWa.className = 'btn btn-outline-success flex-fill';
const hasSpaces = /\s/.test(val); emailSection.style.display = 'block';
waSection.style.display = 'none';
if (val.length > 0 && (!gmailPattern.test(val) || hasSpaces)) {
emailError.style.display = 'block';
this.classList.add('is-invalid');
submitBtn.disabled = true;
} else { } else {
emailError.style.display = 'none'; btnEmail.className = 'btn btn-outline-success flex-fill';
this.classList.remove('is-invalid'); btnWa.className = 'btn btn-success flex-fill';
submitBtn.disabled = val.length === 0; 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(); if (e.key === ' ') e.preventDefault();
}); });
}); });

View File

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