Fix admin organization proof image preview path and implement forgot password feature with rate limiting and check email warning

This commit is contained in:
ilham_gmail
2026-06-11 20:38:28 +07:00
parent cbbd683470
commit b6f150ef86
5 changed files with 219 additions and 49 deletions
+7 -12
View File
@@ -1,18 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="id"> <html lang="id">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebGIS Portal - SIG UAS</title> <title>Tugas Akhir SIG</title>
</head> </head>
<body>
<h1>WebGIS Portal - Tugas Akhir SIG</h1>
<p>Selamat datang di portal integrasi aplikasi Sistem Informasi Geografis (SIG) untuk UAS.</p>
<body>
<h1>Tugas Akhir SIG</h1>
<h2>Daftar Aplikasi:</h2> <h2>Daftar Aplikasi:</h2>
<ul> <ul>
<li><a href="/poverty-mapping/">Aplikasi Poverty Mapping (Project Utama)</a></li> <li><a href="/poverty-mapping/">Aplikasi Poverty Mapping (Project Utama)</a></li>
<li><a href="/tugas-spbu/">Aplikasi Tugas SPBU &amp; Tata Ruang</a></li> <li><a href="/tugas-spbu/">Aplikasi Tugas SPBU</a></li>
</ul> </ul>
<h2>Akun Demo Penilaian:</h2> <h2>Akun Demo Penilaian:</h2>
@@ -22,7 +22,7 @@
<th>Aplikasi</th> <th>Aplikasi</th>
<th>Role</th> <th>Role</th>
<th>Kredensial / Email</th> <th>Kredensial / Email</th>
<th>Password / Token</th> <th>Password</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -38,12 +38,6 @@
<td>manager@example.com</td> <td>manager@example.com</td>
<td>password</td> <td>password</td>
</tr> </tr>
<tr>
<td>Tugas SPBU</td>
<td>Admin SPBU</td>
<td>Input API Key di UI</td>
<td>8f3b7e6a9c4d2f1a6b8c9d0e4f7a1b2c</td>
</tr>
</tbody> </tbody>
</table> </table>
@@ -53,4 +47,5 @@
<p>&copy; 2026 Portal WebGIS UAS.</p> <p>&copy; 2026 Portal WebGIS UAS.</p>
</footer> </footer>
</body> </body>
</html> </html>
+50 -11
View File
@@ -530,11 +530,27 @@ function openAuthModal(mode){
'<div style="font-size:12px;color:#64748b;margin-top:8px">Akun akan berstatus menunggu persetujuan admin setelah pendaftaran berhasil.</div>'; '<div style="font-size:12px;color:#64748b;margin-top:8px">Akun akan berstatus menunggu persetujuan admin setelah pendaftaran berhasil.</div>';
if(switchArea){ switchArea.innerHTML = '<span>Sudah punya akun? <button type="button" id="auth-switch-to-login" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Masuk</button></span>'; } if(switchArea){ switchArea.innerHTML = '<span>Sudah punya akun? <button type="button" id="auth-switch-to-login" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Masuk</button></span>'; }
if(submitBtn) submitBtn.textContent = 'Daftar'; if(submitBtn) submitBtn.textContent = 'Daftar';
} else { } else if(mode === 'login'){
title.textContent = 'Masuk'; title.textContent = 'Masuk';
fields.innerHTML = '<div class="auth-field"><label>Email<input name="email" type="email" required></label></div><div class="auth-field"><label>Password<input name="password" type="password" required></label></div>'; fields.innerHTML = '<div class="auth-field"><label><span class="label-title">Email</span><input name="email" type="email" required></label></div>' +
'<div class="auth-field"><label><span class="label-title">Password</span><input name="password" type="password" required></label></div>' +
'<div style="text-align:right;margin-top:-6px;margin-bottom:12px">' +
'<button type="button" id="auth-forgot-password-link" style="background:transparent;border:0;padding:0;color:#3b82f6;cursor:pointer;font-size:13px">Lupa Password?</button>' +
'</div>';
if(switchArea){ switchArea.innerHTML = '<span>Ingin daftar pengelola? <button type="button" id="auth-switch-to-register" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Daftar di sini</button></span>'; } if(switchArea){ switchArea.innerHTML = '<span>Ingin daftar pengelola? <button type="button" id="auth-switch-to-register" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Daftar di sini</button></span>'; }
if(submitBtn) submitBtn.textContent = 'Masuk'; if(submitBtn) submitBtn.textContent = 'Masuk';
} else if(mode === 'forgot_password'){
title.textContent = 'Lupa Password';
fields.innerHTML =
'<div class="auth-field"><label><span class="label-title">Email <span class="required-star">*</span></span><input name="email" type="email" required></label></div>'+
'<div style="display:flex;gap:8px;margin-top:12px;margin-bottom:12px">' +
'<button type="button" id="auth-send-forgot-code-btn" class="btn" style="flex:1;padding:10px;background:#3b82f6;color:white;border:none;border-radius:4px;cursor:pointer;font-size:14px">Kirim Kode Verifikasi</button>' +
'</div>' +
'<div class="auth-field"><label><span class="label-title">Kode Verifikasi <span class="required-star">*</span></span><input name="verify_code" type="text" placeholder="Masukkan 6 digit" maxlength="6" pattern="[0-9]{6}" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Password Baru <span class="required-star">*</span></span><input name="password" type="password" required></label></div>'+
'<div class="auth-field"><label><span class="label-title">Konfirmasi Password Baru <span class="required-star">*</span></span><input name="password_confirm" type="password" required></label></div>';
if(switchArea){ switchArea.innerHTML = '<span>Kembali ke <button type="button" id="auth-switch-to-login" class="data-link" style="background:transparent;border:0;padding:0;cursor:pointer;margin-left:4px">Masuk</button></span>'; }
if(submitBtn) submitBtn.textContent = 'Reset Password';
} }
modal.classList.remove('hidden'); modal.classList.remove('hidden');
modal.setAttribute('aria-hidden','false'); modal.setAttribute('aria-hidden','false');
@@ -542,8 +558,10 @@ function openAuthModal(mode){
try{ try{
const switchToRegister = document.getElementById('auth-switch-to-register'); const switchToRegister = document.getElementById('auth-switch-to-register');
const switchToLogin = document.getElementById('auth-switch-to-login'); const switchToLogin = document.getElementById('auth-switch-to-login');
const forgotPasswordLink = document.getElementById('auth-forgot-password-link');
if(switchToRegister) switchToRegister.onclick = function(){ openAuthModal('register'); }; if(switchToRegister) switchToRegister.onclick = function(){ openAuthModal('register'); };
if(switchToLogin) switchToLogin.onclick = function(){ openAuthModal('login'); }; if(switchToLogin) switchToLogin.onclick = function(){ openAuthModal('login'); };
if(forgotPasswordLink) forgotPasswordLink.onclick = function(){ openAuthModal('forgot_password'); };
}catch(e){} }catch(e){}
if(mode === 'register'){ if(mode === 'register'){
const orgProof = document.getElementById('org_proof_input'); const orgProof = document.getElementById('org_proof_input');
@@ -567,15 +585,16 @@ function openAuthModal(mode){
}); });
} }
} }
const form = document.getElementById('auth-modal-form');
form.onsubmit = async function(ev){ form.onsubmit = async function(ev){
ev.preventDefault(); ev.preventDefault();
// Regular form submission (register or login) let endpoint = 'src/api/auth/login.php';
const endpoint = mode === 'register' ? 'src/api/auth/register.php' : 'src/api/auth/login.php'; if(mode === 'register') endpoint = 'src/api/auth/register.php';
if(mode === 'forgot_password') endpoint = 'src/api/auth/reset_password.php';
try{ try{
let resp, jr; let resp, jr;
if(mode === 'register'){ if(mode === 'register' || mode === 'forgot_password'){
// Validate password length and match // Validate password length and match
const passwordInput = form.querySelector('input[name="password"]'); const passwordInput = form.querySelector('input[name="password"]');
const password = passwordInput ? passwordInput.value : ''; const password = passwordInput ? passwordInput.value : '';
@@ -591,6 +610,7 @@ function openAuthModal(mode){
return; return;
} }
if(mode === 'register'){
// client-side validation for org_proof: size <=5MB and allowed extensions // client-side validation for org_proof: size <=5MB and allowed extensions
const fileInput = form.querySelector('input[name="org_proof"]') || document.getElementById('org_proof_input'); const fileInput = form.querySelector('input[name="org_proof"]') || document.getElementById('org_proof_input');
if(!fileInput){ showToast('Input bukti organisasi tidak ditemukan', 'error', 4000); return; } if(!fileInput){ showToast('Input bukti organisasi tidak ditemukan', 'error', 4000); return; }
@@ -601,6 +621,7 @@ function openAuthModal(mode){
const ext = (f.name || '').split('.').pop().toLowerCase(); const ext = (f.name || '').split('.').pop().toLowerCase();
if(f.size > maxSize){ showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000); return; } if(f.size > maxSize){ showToast('Ukuran file bukti organisasi maksimal 5MB', 'error', 5000); return; }
if(!allowed.includes(ext)){ showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000); return; } if(!allowed.includes(ext)){ showToast('Jenis file tidak diperbolehkan. Hanya .png, .jpg, .jpeg, .svg, atau .pdf.', 'error', 5000); return; }
}
// Check verify code // Check verify code
const codeInput = form.querySelector('input[name="verify_code"]'); const codeInput = form.querySelector('input[name="verify_code"]');
@@ -610,6 +631,7 @@ function openAuthModal(mode){
return; return;
} }
if(mode === 'register'){
const fd = new FormData(form); const fd = new FormData(form);
resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(null), body: fd, credentials: 'same-origin' }); resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(null), body: fd, credentials: 'same-origin' });
jr = await resp.json().catch(()=>null); jr = await resp.json().catch(()=>null);
@@ -620,14 +642,25 @@ function openAuthModal(mode){
resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(), body: JSON.stringify(payload), credentials: 'same-origin' }); resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(), body: JSON.stringify(payload), credentials: 'same-origin' });
jr = await resp.json().catch(()=>null); jr = await resp.json().catch(()=>null);
} }
} else {
const fd = new FormData(form);
const payload = {};
for(const [k,v] of fd.entries()) payload[k] = v;
resp = await fetch(endpoint, { method: 'POST', headers: buildHeaders(), body: JSON.stringify(payload), credentials: 'same-origin' });
jr = await resp.json().catch(()=>null);
}
if(!resp.ok || !jr || jr.success === false){ if(!resp.ok || !jr || jr.success === false){
showToast((jr && (jr.error || jr.status)) ? (jr.error || jr.status) : 'Gagal memproses respons dari server', 'error', 5000); showToast((jr && (jr.error || jr.status || jr.message)) ? (jr.error || jr.status || jr.message) : 'Gagal memproses respons dari server', 'error', 5000);
return; return;
} }
if(mode === 'register'){ if(mode === 'register'){
authRegisterState = { code_sent: false, code: null, email: null }; authRegisterState = { code_sent: false, code: null, email: null };
closeAuthModal(); closeAuthModal();
showToast('Pendaftaran berhasil! Akun menunggu persetujuan admin.', 'success', 5000); showToast('Pendaftaran berhasil! Akun menunggu persetujuan admin.', 'success', 5000);
} else if(mode === 'forgot_password'){
authRegisterState = { code_sent: false, code: null, email: null };
closeAuthModal();
showToast('Reset password berhasil! Silakan masuk kembali.', 'success', 5000);
} else { } else {
if(jr && jr.token){ syncAuthToken(jr.token); } if(jr && jr.token){ syncAuthToken(jr.token); }
if(jr && jr.user){ syncCurrentUser(jr.user); } if(jr && jr.user){ syncCurrentUser(jr.user); }
@@ -639,9 +672,9 @@ function openAuthModal(mode){
}catch(e){ console.error(e); showToast('Gagal memproses autentikasi', 'error', 5000); } }catch(e){ console.error(e); showToast('Gagal memproses autentikasi', 'error', 5000); }
}; };
// Setup event listeners for registration "Send Verification Code" button // Setup event listeners for registration and forgot password "Send Verification Code" buttons
if(mode === 'register'){ const wireVerificationBtn = function(btnId) {
const sendCodeBtn = document.getElementById('auth-send-code-btn'); const sendCodeBtn = document.getElementById(btnId);
if(sendCodeBtn){ if(sendCodeBtn){
sendCodeBtn.onclick = async function(ev){ sendCodeBtn.onclick = async function(ev){
ev.preventDefault(); ev.preventDefault();
@@ -724,7 +757,7 @@ function openAuthModal(mode){
} }
} }
showToast('Kode verifikasi dikirim ke email Anda!', 'success', 4000); showToast('Kode verifikasi terkirim ke email Anda! Silakan cek email Anda.', 'success', 6000);
// Cooldown countdown timer // Cooldown countdown timer
let cooldown = 30; let cooldown = 30;
@@ -749,6 +782,12 @@ function openAuthModal(mode){
} }
}; };
} }
};
if(mode === 'register'){
wireVerificationBtn('auth-send-code-btn');
} else if(mode === 'forgot_password'){
wireVerificationBtn('auth-send-forgot-code-btn');
} }
} }
+38 -8
View File
@@ -73,18 +73,48 @@ if(!in_array($mime, $allowedMimes) || !in_array($ext, $allowedExts)){
exit; exit;
} }
// Verify code matches what was sent (simple check: hash it and compare)
// In production, store code server-side with TTL
$codeHash = hash('sha256', $verify_code);
// For now, we'll do a simple verification - just accept it
// In production: look up in cache/db, check expiry, mark used
// For MVP: accept any 6-digit code (since we sent it ourselves)
if(!preg_match('/^\d{6}$/', $verify_code)){ if(!preg_match('/^\d{6}$/', $verify_code)){
http_response_code(400); http_response_code(400);
echo json_encode(['success'=>false,'error'=>'invalid_verify_code_format']); echo json_encode(['success'=>false,'error'=>'Format kode verifikasi salah (harus 6 digit)']);
exit; exit;
} }
// Verify code against database
$inputHash = hash('sha256', $verify_code);
$vStmt = $conn->prepare("SELECT code_hash, expires_at FROM verification_attempts WHERE email = ?");
$vStmt->bind_param('s', $email);
$vStmt->execute();
$vRes = $vStmt->get_result();
$vRow = $vRes ? $vRes->fetch_assoc() : null;
$vStmt->close();
if(!$vRow || !$vRow['code_hash']){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Kode verifikasi belum dikirim atau telah kedaluwarsa']);
exit;
}
$dbHash = $vRow['code_hash'];
$expiry = strtotime($vRow['expires_at']);
if(time() > $expiry){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Kode verifikasi telah kedaluwarsa']);
exit;
}
if(!hash_equals($dbHash, $inputHash)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Kode verifikasi tidak cocok']);
exit;
}
// Code matches! Clear the verification code hash to prevent reuse
$vClear = $conn->prepare("UPDATE verification_attempts SET code_hash = NULL, expires_at = NULL WHERE email = ?");
$vClear->bind_param('s', $email);
$vClear->execute();
$vClear->close();
// validate email and password // validate email and password
if(!filter_var($email, FILTER_VALIDATE_EMAIL)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_email']); exit; } if(!filter_var($email, FILTER_VALIDATE_EMAIL)){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'invalid_email']); exit; }
if(strlen($password) < 8){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'password_too_short']); exit; } if(strlen($password) < 8){ http_response_code(400); echo json_encode(['success'=>false,'error'=>'password_too_short']); exit; }
@@ -120,7 +150,7 @@ if(!$res){ http_response_code(500); echo json_encode(['success'=>false,'error'=>
$orgProofPath = null; $orgProofPath = null;
if(isset($_FILES['org_proof']) && $_FILES['org_proof']['error'] === UPLOAD_ERR_OK){ if(isset($_FILES['org_proof']) && $_FILES['org_proof']['error'] === UPLOAD_ERR_OK){
$file = $_FILES['org_proof']; $file = $_FILES['org_proof'];
$uDir = __DIR__ . '/../../uploads/org_proofs'; $uDir = __DIR__ . '/../../../uploads/org_proofs';
if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); } if(!is_dir($uDir)) { @mkdir($uDir, 0755, true); @chmod($uDir, 0755); }
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name'])); $safeName = preg_replace('/[^a-zA-Z0-9._-]/','_', basename($file['name']));
@@ -0,0 +1,103 @@
<?php
include __DIR__ . '/../../config/koneksi.php';
header('Content-Type: application/json; charset=utf-8');
// Support JSON and form-encoded
$data = null;
if($_SERVER['REQUEST_METHOD'] === 'POST'){
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if(strpos($contentType, 'application/json') !== false){
$data = json_decode(file_get_contents('php://input'), true);
} else {
$data = $_POST;
}
}
$email = isset($data['email']) ? strtolower(trim($data['email'])) : null;
$verify_code = isset($data['verify_code']) ? trim($data['verify_code']) : null;
$password = isset($data['password']) ? $data['password'] : null;
if(!$email || !filter_var($email, FILTER_VALIDATE_EMAIL)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Email tidak valid']);
exit;
}
if(!$verify_code || !preg_match('/^\d{6}$/', $verify_code)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Format kode verifikasi salah (harus 6 digit)']);
exit;
}
if(!$password || strlen($password) < 8){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Password minimal harus 8 karakter']);
exit;
}
// 1. Verify user exists
$st = $conn->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
if($st){
$st->bind_param('s', $email);
$st->execute();
$r = $st->get_result();
if(!$r || !$r->fetch_assoc()){
http_response_code(404);
echo json_encode(['success'=>false,'error'=>'Email tidak terdaftar sebagai pengelola']);
exit;
}
$st->close();
}
// 2. Verify code against database
$inputHash = hash('sha256', $verify_code);
$vStmt = $conn->prepare("SELECT code_hash, expires_at FROM verification_attempts WHERE email = ?");
$vStmt->bind_param('s', $email);
$vStmt->execute();
$vRes = $vStmt->get_result();
$vRow = $vRes ? $vRes->fetch_assoc() : null;
$vStmt->close();
if(!$vRow || !$vRow['code_hash']){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Kode verifikasi belum dikirim atau telah kedaluwarsa']);
exit;
}
$dbHash = $vRow['code_hash'];
$expiry = strtotime($vRow['expires_at']);
if(time() > $expiry){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Kode verifikasi telah kedaluwarsa']);
exit;
}
if(!hash_equals($dbHash, $inputHash)){
http_response_code(400);
echo json_encode(['success'=>false,'error'=>'Kode verifikasi tidak cocok']);
exit;
}
// 3. Clear code hash to prevent reuse
$vClear = $conn->prepare("UPDATE verification_attempts SET code_hash = NULL, expires_at = NULL WHERE email = ?");
$vClear->bind_param('s', $email);
$vClear->execute();
$vClear->close();
// 4. Update password
$hash = password_hash($password, PASSWORD_DEFAULT);
$up = $conn->prepare('UPDATE users SET password_hash = ? WHERE email = ?');
if($up){
$up->bind_param('ss', $hash, $email);
$res = $up->execute();
$up->close();
if($res){
echo json_encode(['success'=>true,'message'=>'Password berhasil diperbarui']);
exit;
}
}
http_response_code(500);
echo json_encode(['success'=>false,'error'=>'Gagal memperbarui password di database']);
?>
@@ -30,6 +30,8 @@ try {
last_attempt_at DATETIME NOT NULL, last_attempt_at DATETIME NOT NULL,
locked_until DATETIME NULL locked_until DATETIME NULL
)"); )");
$conn->query("ALTER TABLE verification_attempts ADD COLUMN IF NOT EXISTS code_hash VARCHAR(128) NULL");
$conn->query("ALTER TABLE verification_attempts ADD COLUMN IF NOT EXISTS expires_at DATETIME NULL");
} catch (Exception $e) {} } catch (Exception $e) {}
$nowStr = date('Y-m-d H:i:s'); $nowStr = date('Y-m-d H:i:s');
@@ -77,6 +79,7 @@ if($row){
// Generate 6-digit code // Generate 6-digit code
$verificationCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); $verificationCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$codeHash = hash('sha256', $verificationCode); $codeHash = hash('sha256', $verificationCode);
$expiresAt = date('Y-m-d H:i:s', $nowTime + 15 * 60); // 15 minutes expiry
$mailSuccess = false; $mailSuccess = false;
try{ try{
@@ -92,15 +95,15 @@ if($row){
$newAttempts = ($row['locked_until'] && $nowTime >= strtotime($row['locked_until'])) ? 1 : ($row['attempts'] + 1); $newAttempts = ($row['locked_until'] && $nowTime >= strtotime($row['locked_until'])) ? 1 : ($row['attempts'] + 1);
$newLockedUntil = ($newAttempts >= 3) ? date('Y-m-d H:i:s', $nowTime + 24 * 3600) : null; $newLockedUntil = ($newAttempts >= 3) ? date('Y-m-d H:i:s', $nowTime + 24 * 3600) : null;
$up = $conn->prepare("UPDATE verification_attempts SET attempts = ?, last_attempt_at = ?, locked_until = ? WHERE email = ?"); $up = $conn->prepare("UPDATE verification_attempts SET attempts = ?, last_attempt_at = ?, locked_until = ?, code_hash = ?, expires_at = ? WHERE email = ?");
$up->bind_param('isss', $newAttempts, $nowStr, $newLockedUntil, $email); $up->bind_param('isssss', $newAttempts, $nowStr, $newLockedUntil, $codeHash, $expiresAt, $email);
$up->execute(); $up->execute();
$up->close(); $up->close();
} else { } else {
$one = 1; $one = 1;
$nullVal = null; $nullVal = null;
$ins = $conn->prepare("INSERT INTO verification_attempts (email, attempts, last_attempt_at, locked_until) VALUES (?, ?, ?, ?)"); $ins = $conn->prepare("INSERT INTO verification_attempts (email, attempts, last_attempt_at, locked_until, code_hash, expires_at) VALUES (?, ?, ?, ?, ?, ?)");
$ins->bind_param('siss', $email, $one, $nowStr, $nullVal); $ins->bind_param('sissss', $email, $one, $nowStr, $nullVal, $codeHash, $expiresAt);
$ins->execute(); $ins->execute();
$ins->close(); $ins->close();
} }