diff --git a/.env.example b/.env.example
index f6bc1a2..6d0ad84 100644
--- a/.env.example
+++ b/.env.example
@@ -1 +1,3 @@
MARIADB_ROOT_PASSWORD=change-this-password
+APP_DB_USER=webgis_app
+APP_DB_PASSWORD=change-this-app-password
diff --git a/01/.htaccess b/01/.htaccess
new file mode 100644
index 0000000..bbfa2a3
--- /dev/null
+++ b/01/.htaccess
@@ -0,0 +1,15 @@
+Options -Indexes
+
+
+ Header always set X-Frame-Options "SAMEORIGIN"
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
+
+
+
+ Require all denied
+
+
+RedirectMatch 404 ^/01/(tests|docs)(/|$)
+RedirectMatch 404 ^/01/\.
diff --git a/01/README.md b/01/README.md
index 0fc54bc..8d122ea 100644
--- a/01/README.md
+++ b/01/README.md
@@ -82,3 +82,15 @@ http://localhost/webgis/01/
- Google Fonts
Dependensi frontend dimuat dari CDN, sehingga demo membutuhkan koneksi internet.
+
+## Public Deployment Hardening
+
+Before exposing `01/` publicly:
+
+- Rotate the seeded admin password.
+- Keep public users read-only.
+- Confirm anonymous POST requests to `api/point`, `api/jalan`, `api/parsil`, and `api/choropleth` return `403`.
+- Confirm `/01/setup_database.sql`, `/01/tests/`, `/01/docs/`, and dotfiles are not reachable by browser.
+- Use a database user with limited app privileges in production.
+
+Production database note: create a MariaDB user with privileges only on `db_webgis_01` and configure `APP_DB_USER` / `APP_DB_PASSWORD`. Do not run the public app with the root database user after initial setup.
diff --git a/01/ambil_data.php b/01/ambil_data.php
index 2ff1c26..1e8a0cb 100644
--- a/01/ambil_data.php
+++ b/01/ambil_data.php
@@ -6,7 +6,6 @@
*/
header('Content-Type: application/json');
-header('Access-Control-Allow-Origin: *');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
@@ -20,9 +19,8 @@ $result = $conn->query(
);
if (!$result) {
- http_response_code(500);
- echo json_encode(['status' => 'error', 'message' => 'Query gagal: ' . $conn->error]);
- exit;
+ error_log('Project 01 legacy point read failed: ' . $conn->error);
+ json_error('Gagal memuat data.', 500);
}
$data = [];
@@ -43,4 +41,4 @@ echo json_encode([
'data' => $data
]);
-$conn->close();
\ No newline at end of file
+$conn->close();
diff --git a/01/api/choropleth/ambil.php b/01/api/choropleth/ambil.php
index 540179c..0ad0fa8 100644
--- a/01/api/choropleth/ambil.php
+++ b/01/api/choropleth/ambil.php
@@ -8,8 +8,8 @@ $result = $conn->query(
);
if ($result === false) {
- echo json_encode(['status' => 'error', 'message' => 'Tabel belum ada. Jalankan setup_database.sql terlebih dahulu. (' . $conn->error . ')']);
- $conn->close(); exit;
+ error_log('Project 01 choropleth metadata read failed: ' . $conn->error);
+ json_error('Gagal memuat layer.', 500);
}
$rows = [];
@@ -26,4 +26,4 @@ while ($r = $result->fetch_assoc()) {
}
$conn->close();
-echo json_encode(['status' => 'success', 'data' => $rows, 'total' => count($rows)]);
+json_success(['data' => $rows, 'total' => count($rows)]);
diff --git a/01/api/choropleth/geojson.php b/01/api/choropleth/geojson.php
index 4c3119e..6074ae2 100644
--- a/01/api/choropleth/geojson.php
+++ b/01/api/choropleth/geojson.php
@@ -4,10 +4,14 @@ header('Content-Type: application/json');
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
+ json_error('ID tidak valid.', 400);
}
-$stmt = $conn->prepare("SELECT geojson FROM choropleth_layers WHERE id = ?");
+$stmt = $conn->prepare("SELECT geojson FROM choropleth_layers WHERE id = ? AND is_visible = 1");
+if (!$stmt) {
+ error_log('Project 01 choropleth geojson prepare failed: ' . $conn->error);
+ json_error('Gagal memuat GeoJSON.', 500);
+}
$stmt->bind_param('i', $id);
$stmt->execute();
$row = $stmt->get_result()->fetch_assoc();
@@ -15,8 +19,7 @@ $stmt->close();
$conn->close();
if (!$row) {
- http_response_code(404);
- echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit;
+ json_error('Layer tidak ditemukan.', 404);
}
echo $row['geojson'];
diff --git a/01/api/choropleth/hapus.php b/01/api/choropleth/hapus.php
index 89e8efd..11ef7c8 100644
--- a/01/api/choropleth/hapus.php
+++ b/01/api/choropleth/hapus.php
@@ -1,26 +1,28 @@
'error', 'message' => 'Method tidak valid.']); exit;
-}
+require_admin_post();
+require_once '../../koneksi.php';
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
+ json_error('ID tidak valid.', 400);
}
$stmt = $conn->prepare("DELETE FROM choropleth_layers WHERE id = ?");
-$stmt->bind_param('i', $id);
-$stmt->execute();
-$affected = $stmt->affected_rows;
-$stmt->close();
-$conn->close();
-
-if ($affected === 0) {
- echo json_encode(['status' => 'error', 'message' => 'Layer tidak ditemukan.']); exit;
+if (!$stmt) {
+ error_log('Project 01 choropleth delete prepare failed: ' . $conn->error);
+ json_error('Gagal menghapus layer.', 500);
}
-echo json_encode(['status' => 'success']);
+$stmt->bind_param('i', $id);
+if (!$stmt->execute()) {
+ error_log('Project 01 choropleth delete failed: ' . $stmt->error);
+ json_error('Gagal menghapus layer.', 500);
+}
+
+if ($stmt->affected_rows <= 0) {
+ json_error('Layer tidak ditemukan.', 404);
+}
+
+json_success();
diff --git a/01/api/choropleth/toggle.php b/01/api/choropleth/toggle.php
index a55d76a..53eb6b5 100644
--- a/01/api/choropleth/toggle.php
+++ b/01/api/choropleth/toggle.php
@@ -1,27 +1,39 @@
'error', 'message' => 'Method tidak valid.']); exit;
-}
+require_admin_post();
+require_once '../../koneksi.php';
$id = (int)($_POST['id'] ?? 0);
if ($id <= 0) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']); exit;
+ json_error('ID tidak valid.', 400);
}
$stmt = $conn->prepare("UPDATE choropleth_layers SET is_visible = 1 - is_visible WHERE id = ?");
+if (!$stmt) {
+ error_log('Project 01 choropleth toggle prepare failed: ' . $conn->error);
+ json_error('Gagal mengubah visibilitas layer.', 500);
+}
+
$stmt->bind_param('i', $id);
-$stmt->execute();
+if (!$stmt->execute()) {
+ error_log('Project 01 choropleth toggle failed: ' . $stmt->error);
+ json_error('Gagal mengubah visibilitas layer.', 500);
+}
$stmt->close();
$stmt2 = $conn->prepare("SELECT is_visible FROM choropleth_layers WHERE id = ?");
+if (!$stmt2) {
+ error_log('Project 01 choropleth visibility prepare failed: ' . $conn->error);
+ json_error('Gagal memuat status layer.', 500);
+}
+
$stmt2->bind_param('i', $id);
$stmt2->execute();
$row = $stmt2->get_result()->fetch_assoc();
-$stmt2->close();
-$conn->close();
-echo json_encode(['status' => 'success', 'is_visible' => (bool)$row['is_visible']]);
+if (!$row) {
+ json_error('Layer tidak ditemukan.', 404);
+}
+
+json_success(['is_visible' => (bool)$row['is_visible']]);
diff --git a/01/api/choropleth/upload.php b/01/api/choropleth/upload.php
index 1984a09..99f59b3 100644
--- a/01/api/choropleth/upload.php
+++ b/01/api/choropleth/upload.php
@@ -1,54 +1,47 @@
'error', 'message' => 'Method tidak valid.']); exit;
-}
+$nama = clean_string($_POST['nama'] ?? '', 150);
+$deskripsi = clean_string($_POST['deskripsi'] ?? '', 500);
+$attrKey = clean_string($_POST['attribute_key'] ?? '', 100);
+$palette = validate_enum($_POST['palette'] ?? '', ['blue', 'orange', 'green', 'purple']) ?? 'blue';
-$nama = trim($_POST['nama'] ?? '');
-$deskripsi = trim($_POST['deskripsi'] ?? '');
-$attr_key = trim($_POST['attribute_key'] ?? '');
-$palette = in_array($_POST['palette'] ?? '', ['blue', 'orange', 'green', 'purple'])
- ? $_POST['palette'] : 'blue';
-
-if (!$nama || !$attr_key) {
- echo json_encode(['status' => 'error', 'message' => 'Nama layer dan atribut wajib diisi.']); exit;
+if ($nama === '' || $attrKey === '') {
+ json_error('Nama layer dan atribut wajib diisi.', 400);
}
if (empty($_FILES['geojson']['tmp_name']) || $_FILES['geojson']['error'] !== UPLOAD_ERR_OK) {
- echo json_encode(['status' => 'error', 'message' => 'File GeoJSON tidak ditemukan atau gagal diupload.']); exit;
+ json_error('File GeoJSON tidak ditemukan atau gagal diupload.', 400);
}
-if ($_FILES['geojson']['size'] > 10 * 1024 * 1024) {
- echo json_encode(['status' => 'error', 'message' => 'File terlalu besar (maksimum 10 MB).']); exit;
+if ($_FILES['geojson']['size'] > MAX_GEOJSON_BYTES) {
+ json_error('File terlalu besar.', 400);
}
$raw = file_get_contents($_FILES['geojson']['tmp_name']);
if ($raw === false) {
- echo json_encode(['status' => 'error', 'message' => 'Gagal membaca file.']); exit;
+ json_error('Gagal membaca file.', 400);
}
-$gj = json_decode($raw, true);
-if (!$gj || !isset($gj['type']) || $gj['type'] !== 'FeatureCollection') {
- echo json_encode(['status' => 'error', 'message' => 'File bukan GeoJSON FeatureCollection yang valid.']); exit;
-}
-if (empty($gj['features'])) {
- echo json_encode(['status' => 'error', 'message' => 'GeoJSON tidak memiliki fitur/data.']); exit;
-}
-
-$first_props = $gj['features'][0]['properties'] ?? [];
-if (!array_key_exists($attr_key, $first_props)) {
- $available = implode(', ', array_keys($first_props));
- echo json_encode(['status' => 'error', 'message' => "Atribut '{$attr_key}' tidak ditemukan. Tersedia: {$available}"]); exit;
+$geojson = decode_geojson($raw);
+if ($geojson === null || !validate_feature_collection($geojson, $attrKey)) {
+ json_error('GeoJSON tidak valid atau atribut choropleth tidak lengkap.', 400);
}
$stmt = $conn->prepare("INSERT INTO choropleth_layers (nama, deskripsi, geojson, attribute_key, palette) VALUES (?, ?, ?, ?, ?)");
-$stmt->bind_param('sssss', $nama, $deskripsi, $raw, $attr_key, $palette);
-$stmt->execute();
-$id = $conn->insert_id;
-$stmt->close();
-$conn->close();
+if (!$stmt) {
+ error_log('Project 01 choropleth upload prepare failed: ' . $conn->error);
+ json_error('Gagal menyimpan layer.', 500);
+}
-echo json_encode(['status' => 'success', 'id' => $id, 'nama' => $nama]);
+$stmt->bind_param('sssss', $nama, $deskripsi, $raw, $attrKey, $palette);
+if (!$stmt->execute()) {
+ error_log('Project 01 choropleth upload failed: ' . $stmt->error);
+ json_error('Gagal menyimpan layer.', 500);
+}
+
+json_success(['id' => $conn->insert_id, 'nama' => $nama]);
diff --git a/01/api/jalan/ambil.php b/01/api/jalan/ambil.php
index e01ae96..3bebeb1 100644
--- a/01/api/jalan/ambil.php
+++ b/01/api/jalan/ambil.php
@@ -6,8 +6,8 @@ require_once '../../koneksi.php';
$result = $conn->query("SELECT id, nama_jalan, status_jalan, geojson, panjang_meter FROM data_jalan ORDER BY id DESC");
if (!$result) {
- echo json_encode(['status' => 'error', 'message' => $conn->error]);
- exit;
+ error_log('Project 01 road read failed: ' . $conn->error);
+ json_error('Gagal memuat data jalan.', 500);
}
$data = [];
@@ -21,4 +21,4 @@ echo json_encode([
'data' => $data
]);
-$conn->close();
\ No newline at end of file
+$conn->close();
diff --git a/01/api/jalan/hapus.php b/01/api/jalan/hapus.php
index da2f2cf..bcdfdf8 100644
--- a/01/api/jalan/hapus.php
+++ b/01/api/jalan/hapus.php
@@ -1,27 +1,28 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
-
-$id = (int) ($_POST['id'] ?? 0);
-if (!$id) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
- exit;
+$id = (int)($_POST['id'] ?? 0);
+if ($id <= 0) {
+ json_error('ID tidak valid.', 400);
}
$stmt = $conn->prepare("DELETE FROM data_jalan WHERE id = ?");
-$stmt->bind_param('i', $id);
-
-if ($stmt->execute() && $stmt->affected_rows > 0) {
- echo json_encode(['status' => 'success', 'message' => 'Data jalan berhasil dihapus']);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
+if (!$stmt) {
+ error_log('Project 01 road delete prepare failed: ' . $conn->error);
+ json_error('Gagal menghapus data jalan.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('i', $id);
+if (!$stmt->execute()) {
+ error_log('Project 01 road delete failed: ' . $stmt->error);
+ json_error('Gagal menghapus data jalan.', 500);
+}
+
+if ($stmt->affected_rows <= 0) {
+ json_error('Data jalan tidak ditemukan.', 404);
+}
+
+json_success(['message' => 'Data jalan berhasil dihapus']);
diff --git a/01/api/jalan/simpan.php b/01/api/jalan/simpan.php
index d5d2083..f4232b7 100644
--- a/01/api/jalan/simpan.php
+++ b/01/api/jalan/simpan.php
@@ -1,56 +1,39 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
+$nama = clean_string($_POST['nama_jalan'] ?? '', 150);
+$statusJalan = validate_enum($_POST['status_jalan'] ?? '', ['Nasional', 'Provinsi', 'Kabupaten']);
+$geojsonRaw = trim($_POST['geojson'] ?? '');
+$decoded = decode_geojson($geojsonRaw);
+$panjang = max(0, (float)($_POST['panjang_meter'] ?? 0));
-$nama = trim($_POST['nama_jalan'] ?? '');
-$status_jalan = trim($_POST['status_jalan'] ?? '');
-$geojson = trim($_POST['geojson'] ?? '');
-$panjang = (float) ($_POST['panjang_meter'] ?? 0);
-
-$allowed_status = ['Nasional', 'Provinsi', 'Kabupaten'];
-
-if (!$nama) {
- echo json_encode(['status' => 'error', 'message' => 'Nama jalan tidak boleh kosong']);
- exit;
-}
-
-if (!in_array($status_jalan, $allowed_status)) {
- echo json_encode(['status' => 'error', 'message' => 'Status jalan tidak valid']);
- exit;
-}
-
-// Validasi JSON
-json_decode($geojson);
-if (json_last_error() !== JSON_ERROR_NONE) {
- echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']);
- exit;
+if ($nama === '' || $statusJalan === null || $decoded === null || !validate_geojson_geometry($decoded, ['LineString'])) {
+ json_error('Data jalan tidak valid.', 400);
}
$stmt = $conn->prepare("INSERT INTO data_jalan (nama_jalan, status_jalan, geojson, panjang_meter) VALUES (?, ?, ?, ?)");
-$stmt->bind_param('sssd', $nama, $status_jalan, $geojson, $panjang);
-
-if ($stmt->execute()) {
- $id = $conn->insert_id;
- echo json_encode([
- 'status' => 'success',
- 'message' => 'Data jalan berhasil disimpan',
- 'data' => [
- 'id' => $id,
- 'nama_jalan' => $nama,
- 'status_jalan' => $status_jalan,
- 'geojson' => $geojson,
- 'panjang_meter' => $panjang
- ]
- ]);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
+if (!$stmt) {
+ error_log('Project 01 road insert prepare failed: ' . $conn->error);
+ json_error('Gagal menyimpan data jalan.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('sssd', $nama, $statusJalan, $geojsonRaw, $panjang);
+if (!$stmt->execute()) {
+ error_log('Project 01 road insert failed: ' . $stmt->error);
+ json_error('Gagal menyimpan data jalan.', 500);
+}
+
+json_success([
+ 'message' => 'Data jalan berhasil disimpan',
+ 'data' => [
+ 'id' => $conn->insert_id,
+ 'nama_jalan' => $nama,
+ 'status_jalan' => $statusJalan,
+ 'geojson' => $geojsonRaw,
+ 'panjang_meter' => $panjang,
+ ],
+]);
diff --git a/01/api/parsil/ambil.php b/01/api/parsil/ambil.php
index 0fcef5d..3aeb860 100644
--- a/01/api/parsil/ambil.php
+++ b/01/api/parsil/ambil.php
@@ -6,8 +6,8 @@ require_once '../../koneksi.php';
$result = $conn->query("SELECT id, nama_parsil, status_kepemilikan, geojson, luas_m2 FROM data_parsil ORDER BY id DESC");
if (!$result) {
- echo json_encode(['status' => 'error', 'message' => $conn->error]);
- exit;
+ error_log('Project 01 parcel read failed: ' . $conn->error);
+ json_error('Gagal memuat data parsil.', 500);
}
$data = [];
@@ -21,4 +21,4 @@ echo json_encode([
'data' => $data
]);
-$conn->close();
\ No newline at end of file
+$conn->close();
diff --git a/01/api/parsil/hapus.php b/01/api/parsil/hapus.php
index 2a6a540..1676794 100644
--- a/01/api/parsil/hapus.php
+++ b/01/api/parsil/hapus.php
@@ -1,27 +1,28 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
-
-$id = (int) ($_POST['id'] ?? 0);
-if (!$id) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
- exit;
+$id = (int)($_POST['id'] ?? 0);
+if ($id <= 0) {
+ json_error('ID tidak valid.', 400);
}
$stmt = $conn->prepare("DELETE FROM data_parsil WHERE id = ?");
-$stmt->bind_param('i', $id);
-
-if ($stmt->execute() && $stmt->affected_rows > 0) {
- echo json_encode(['status' => 'success', 'message' => 'Data parsil berhasil dihapus']);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
+if (!$stmt) {
+ error_log('Project 01 parcel delete prepare failed: ' . $conn->error);
+ json_error('Gagal menghapus data parsil.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('i', $id);
+if (!$stmt->execute()) {
+ error_log('Project 01 parcel delete failed: ' . $stmt->error);
+ json_error('Gagal menghapus data parsil.', 500);
+}
+
+if ($stmt->affected_rows <= 0) {
+ json_error('Data parsil tidak ditemukan.', 404);
+}
+
+json_success(['message' => 'Data parsil berhasil dihapus']);
diff --git a/01/api/parsil/simpan.php b/01/api/parsil/simpan.php
index a9341e8..20ba04c 100644
--- a/01/api/parsil/simpan.php
+++ b/01/api/parsil/simpan.php
@@ -1,55 +1,39 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
+$nama = clean_string($_POST['nama_parsil'] ?? '', 150);
+$status = validate_enum($_POST['status_kepemilikan'] ?? '', ['SHM', 'HGB', 'HGU', 'HP']);
+$geojsonRaw = trim($_POST['geojson'] ?? '');
+$decoded = decode_geojson($geojsonRaw);
+$luas = max(0, (float)($_POST['luas_m2'] ?? 0));
-$nama = trim($_POST['nama_parsil'] ?? '');
-$status = trim($_POST['status_kepemilikan'] ?? '');
-$geojson = trim($_POST['geojson'] ?? '');
-$luas = (float) ($_POST['luas_m2'] ?? 0);
-
-$allowed_status = ['SHM', 'HGB', 'HGU', 'HP'];
-
-if (!$nama) {
- echo json_encode(['status' => 'error', 'message' => 'Nama parsil tidak boleh kosong']);
- exit;
-}
-
-if (!in_array($status, $allowed_status)) {
- echo json_encode(['status' => 'error', 'message' => 'Status kepemilikan tidak valid']);
- exit;
-}
-
-json_decode($geojson);
-if (json_last_error() !== JSON_ERROR_NONE) {
- echo json_encode(['status' => 'error', 'message' => 'Data geometri tidak valid']);
- exit;
+if ($nama === '' || $status === null || $decoded === null || !validate_geojson_geometry($decoded, ['Polygon'])) {
+ json_error('Data parsil tidak valid.', 400);
}
$stmt = $conn->prepare("INSERT INTO data_parsil (nama_parsil, status_kepemilikan, geojson, luas_m2) VALUES (?, ?, ?, ?)");
-$stmt->bind_param('sssd', $nama, $status, $geojson, $luas);
-
-if ($stmt->execute()) {
- $id = $conn->insert_id;
- echo json_encode([
- 'status' => 'success',
- 'message' => 'Data parsil berhasil disimpan',
- 'data' => [
- 'id' => $id,
- 'nama_parsil' => $nama,
- 'status_kepemilikan' => $status,
- 'geojson' => $geojson,
- 'luas_m2' => $luas
- ]
- ]);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
+if (!$stmt) {
+ error_log('Project 01 parcel insert prepare failed: ' . $conn->error);
+ json_error('Gagal menyimpan data parsil.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('sssd', $nama, $status, $geojsonRaw, $luas);
+if (!$stmt->execute()) {
+ error_log('Project 01 parcel insert failed: ' . $stmt->error);
+ json_error('Gagal menyimpan data parsil.', 500);
+}
+
+json_success([
+ 'message' => 'Data parsil berhasil disimpan',
+ 'data' => [
+ 'id' => $conn->insert_id,
+ 'nama_parsil' => $nama,
+ 'status_kepemilikan' => $status,
+ 'geojson' => $geojsonRaw,
+ 'luas_m2' => $luas,
+ ],
+]);
diff --git a/01/api/point/ambil.php b/01/api/point/ambil.php
index 4bdabc6..f3beef5 100644
--- a/01/api/point/ambil.php
+++ b/01/api/point/ambil.php
@@ -6,8 +6,8 @@ require_once '../../koneksi.php';
$result = $conn->query("SELECT id, nama_tempat, no_wa, buka_24jam, latitude, longitude FROM lokasi_usaha ORDER BY id DESC");
if (!$result) {
- echo json_encode(['status' => 'error', 'message' => $conn->error]);
- exit;
+ error_log('Project 01 point read failed: ' . $conn->error);
+ json_error('Gagal memuat data lokasi.', 500);
}
$data = [];
@@ -21,4 +21,4 @@ echo json_encode([
'data' => $data
]);
-$conn->close();
\ No newline at end of file
+$conn->close();
diff --git a/01/api/point/hapus.php b/01/api/point/hapus.php
index 014ecd0..31150a6 100644
--- a/01/api/point/hapus.php
+++ b/01/api/point/hapus.php
@@ -1,27 +1,28 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
-
-$id = (int) ($_POST['id'] ?? 0);
-if (!$id) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
- exit;
+$id = (int)($_POST['id'] ?? 0);
+if ($id <= 0) {
+ json_error('ID tidak valid.', 400);
}
$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?");
-$stmt->bind_param('i', $id);
-
-if ($stmt->execute() && $stmt->affected_rows > 0) {
- echo json_encode(['status' => 'success', 'message' => 'Data berhasil dihapus']);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Data tidak ditemukan atau gagal dihapus']);
+if (!$stmt) {
+ error_log('Project 01 point delete prepare failed: ' . $conn->error);
+ json_error('Gagal menghapus data.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('i', $id);
+if (!$stmt->execute()) {
+ error_log('Project 01 point delete failed: ' . $stmt->error);
+ json_error('Gagal menghapus data.', 500);
+}
+
+if ($stmt->affected_rows <= 0) {
+ json_error('Data tidak ditemukan.', 404);
+}
+
+json_success(['message' => 'Data berhasil dihapus']);
diff --git a/01/api/point/simpan.php b/01/api/point/simpan.php
index 6e4b787..6000cec 100644
--- a/01/api/point/simpan.php
+++ b/01/api/point/simpan.php
@@ -1,44 +1,40 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
-
-$nama = trim($_POST['nama_tempat'] ?? '');
-$wa = trim($_POST['no_wa'] ?? '');
-$buka24 = isset($_POST['buka_24jam']) ? (int) $_POST['buka_24jam'] : 0;
-$lat = (float) ($_POST['latitude'] ?? 0);
-$lng = (float) ($_POST['longitude'] ?? 0);
-
-if (!$nama) {
- echo json_encode(['status' => 'error', 'message' => 'Nama tempat tidak boleh kosong']);
- exit;
+$nama = clean_string($_POST['nama_tempat'] ?? '', 150);
+$wa = clean_string($_POST['no_wa'] ?? '', 30);
+$buka24 = (int)($_POST['buka_24jam'] ?? 0) === 1 ? 1 : 0;
+$coords = validate_lat_lng($_POST['latitude'] ?? null, $_POST['longitude'] ?? null);
+
+if ($nama === '' || $coords === null) {
+ json_error('Nama tempat dan koordinat valid wajib diisi.', 400);
}
+[$lat, $lng] = $coords;
$stmt = $conn->prepare("INSERT INTO lokasi_usaha (nama_tempat, no_wa, buka_24jam, latitude, longitude) VALUES (?, ?, ?, ?, ?)");
-$stmt->bind_param('ssidd', $nama, $wa, $buka24, $lat, $lng);
-
-if ($stmt->execute()) {
- $id = $conn->insert_id;
- echo json_encode([
- 'status' => 'success',
- 'message' => 'Data berhasil disimpan',
- 'data' => [
- 'id' => $id,
- 'nama_tempat' => $nama,
- 'no_wa' => $wa,
- 'buka_24jam' => $buka24,
- 'latitude' => $lat,
- 'longitude' => $lng
- ]
- ]);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Gagal menyimpan: ' . $stmt->error]);
+if (!$stmt) {
+ error_log('Project 01 point insert prepare failed: ' . $conn->error);
+ json_error('Gagal menyimpan data.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('ssidd', $nama, $wa, $buka24, $lat, $lng);
+if (!$stmt->execute()) {
+ error_log('Project 01 point insert failed: ' . $stmt->error);
+ json_error('Gagal menyimpan data.', 500);
+}
+
+json_success([
+ 'message' => 'Data berhasil disimpan',
+ 'data' => [
+ 'id' => $conn->insert_id,
+ 'nama_tempat' => $nama,
+ 'no_wa' => $wa,
+ 'buka_24jam' => $buka24,
+ 'latitude' => $lat,
+ 'longitude' => $lng,
+ ],
+]);
diff --git a/01/api/point/update_posisi.php b/01/api/point/update_posisi.php
index 2e52b44..5b81490 100644
--- a/01/api/point/update_posisi.php
+++ b/01/api/point/update_posisi.php
@@ -1,30 +1,32 @@
'error', 'message' => 'Method not allowed']);
- exit;
-}
-
-$id = (int) ($_POST['id'] ?? 0);
-$lat = (float) ($_POST['latitude'] ?? 0);
-$lng = (float) ($_POST['longitude'] ?? 0);
-
-if (!$id) {
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid']);
- exit;
+$id = (int)($_POST['id'] ?? 0);
+$coords = validate_lat_lng($_POST['latitude'] ?? null, $_POST['longitude'] ?? null);
+
+if ($id <= 0 || $coords === null) {
+ json_error('ID atau koordinat tidak valid.', 400);
}
+[$lat, $lng] = $coords;
$stmt = $conn->prepare("UPDATE lokasi_usaha SET latitude = ?, longitude = ? WHERE id = ?");
-$stmt->bind_param('ddi', $lat, $lng, $id);
-
-if ($stmt->execute() && $stmt->affected_rows > 0) {
- echo json_encode(['status' => 'success', 'message' => 'Posisi diperbarui']);
-} else {
- echo json_encode(['status' => 'error', 'message' => 'Gagal memperbarui posisi']);
+if (!$stmt) {
+ error_log('Project 01 point position prepare failed: ' . $conn->error);
+ json_error('Gagal memperbarui posisi.', 500);
}
-$stmt->close();
-$conn->close();
\ No newline at end of file
+$stmt->bind_param('ddi', $lat, $lng, $id);
+if (!$stmt->execute()) {
+ error_log('Project 01 point position update failed: ' . $stmt->error);
+ json_error('Gagal memperbarui posisi.', 500);
+}
+
+if ($stmt->affected_rows <= 0) {
+ json_error('Data tidak ditemukan atau posisi tidak berubah.', 404);
+}
+
+json_success(['message' => 'Posisi diperbarui']);
diff --git a/01/auth/helper.php b/01/auth/helper.php
index adcb987..167d2c2 100644
--- a/01/auth/helper.php
+++ b/01/auth/helper.php
@@ -1,20 +1,156 @@
0, 'httponly' => true, 'samesite' => 'Strict']);
- session_start();
+
+const SESSION_IDLE_TIMEOUT = 1800;
+const LOGIN_MAX_ATTEMPTS = 5;
+const LOGIN_LOCK_SECONDS = 300;
+
+function is_https(): bool
+{
+ if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
+ return true;
}
+ return ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
}
-function is_admin(): bool {
+function clear_session_cookie(): void
+{
+ if (session_status() === PHP_SESSION_NONE) {
+ return;
+ }
+
+ $params = session_get_cookie_params();
+ setcookie(session_name(), '', [
+ 'expires' => time() - 42000,
+ 'path' => $params['path'] ?? '/',
+ 'domain' => $params['domain'] ?? '',
+ 'secure' => (bool)($params['secure'] ?? false),
+ 'httponly' => true,
+ 'samesite' => $params['samesite'] ?? 'Strict',
+ ]);
+}
+
+function start_session(): void
+{
+ if (session_status() === PHP_SESSION_NONE) {
+ session_set_cookie_params([
+ 'lifetime' => 0,
+ 'path' => '/',
+ 'secure' => is_https(),
+ 'httponly' => true,
+ 'samesite' => 'Strict',
+ ]);
+ session_start();
+ }
+
+ $now = time();
+ $lastSeen = (int)($_SESSION['last_seen_at'] ?? $now);
+ if (($now - $lastSeen) > SESSION_IDLE_TIMEOUT) {
+ $_SESSION = [];
+ clear_session_cookie();
+ session_destroy();
+ session_start();
+ }
+ $_SESSION['last_seen_at'] = $now;
+}
+
+function harden_successful_login(): void
+{
+ start_session();
+ session_regenerate_id(true);
+ $_SESSION['last_seen_at'] = time();
+}
+
+function is_admin(): bool
+{
start_session();
return !empty($_SESSION['admin_id']);
}
-function require_admin_json(): void {
+function login_throttle_key(string $username): string
+{
+ $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+ return hash('sha256', strtolower(trim($username)) . '|' . $ip);
+}
+
+function login_is_throttled(string $username): bool
+{
+ start_session();
+ $entry = $_SESSION['login_throttle'][login_throttle_key($username)] ?? null;
+ if (!is_array($entry)) {
+ return false;
+ }
+ return (int)($entry['locked_until'] ?? 0) > time();
+}
+
+function login_record_failure(string $username): void
+{
+ start_session();
+ $key = login_throttle_key($username);
+ $entry = $_SESSION['login_throttle'][$key] ?? ['count' => 0, 'locked_until' => 0];
+ $entry['count'] = (int)$entry['count'] + 1;
+ if ($entry['count'] >= LOGIN_MAX_ATTEMPTS) {
+ $entry['locked_until'] = time() + LOGIN_LOCK_SECONDS;
+ $entry['count'] = 0;
+ }
+ $_SESSION['login_throttle'][$key] = $entry;
+}
+
+function login_record_success(string $username): void
+{
+ start_session();
+ unset($_SESSION['login_throttle'][login_throttle_key($username)]);
+}
+
+function csrf_token(): string
+{
+ start_session();
+ if (empty($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+ return $_SESSION['csrf_token'];
+}
+
+function verify_csrf_token(?string $token): bool
+{
+ start_session();
+ return is_string($token)
+ && isset($_SESSION['csrf_token'])
+ && hash_equals($_SESSION['csrf_token'], $token);
+}
+
+function json_response(array $payload, int $statusCode = 200): void
+{
+ http_response_code($statusCode);
+ header('Content-Type: application/json');
+ echo json_encode($payload);
+ exit;
+}
+
+function json_error(string $message, int $statusCode = 400): void
+{
+ json_response(['status' => 'error', 'message' => $message], $statusCode);
+}
+
+function json_success(array $payload = []): void
+{
+ json_response(['status' => 'success'] + $payload);
+}
+
+function require_admin_json(): void
+{
if (!is_admin()) {
- http_response_code(403);
- echo json_encode(['status' => 'error', 'message' => 'Akses ditolak. Silahkan login terlebih dahulu.']);
- exit;
+ json_error('Akses ditolak. Silahkan login terlebih dahulu.', 403);
+ }
+}
+
+function require_admin_post(): void
+{
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ json_error('Method tidak valid.', 405);
+ }
+ require_admin_json();
+ $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
+ if (!verify_csrf_token($token)) {
+ json_error('Token keamanan tidak valid.', 403);
}
}
diff --git a/01/hapus.php b/01/hapus.php
index e4c07d6..96c3d1e 100644
--- a/01/hapus.php
+++ b/01/hapus.php
@@ -1,44 +1,5 @@
'error', 'message' => 'Metode tidak diizinkan.']);
- exit;
-}
-
-$idRaw = $_POST['id'] ?? null;
-
-if ($idRaw === null || !is_numeric($idRaw) || (int)$idRaw <= 0) {
- http_response_code(400);
- echo json_encode(['status' => 'error', 'message' => 'ID tidak valid.']);
- exit;
-}
-
-$id = (int)$idRaw;
-
-$stmt = $conn->prepare("DELETE FROM lokasi_usaha WHERE id = ?");
-
-if (!$stmt) {
- http_response_code(500);
- echo json_encode(['status' => 'error', 'message' => 'Persiapan query gagal: ' . $conn->error]);
- exit;
-}
-
-$stmt->bind_param('i', $id);
-
-if ($stmt->execute()) {
- if ($stmt->affected_rows > 0) {
- echo json_encode(['status' => 'success', 'message' => 'Lokasi berhasil dihapus.']);
- } else {
- http_response_code(404);
- echo json_encode(['status' => 'error', 'message' => 'Lokasi tidak ditemukan.']);
- }
-} else {
- http_response_code(500);
- echo json_encode(['status' => 'error', 'message' => $stmt->error]);
-}
-
-$stmt->close();
-$conn->close();
+http_response_code(410);
+json_error('Endpoint legacy sudah dinonaktifkan. Gunakan API di folder api/.', 410);
diff --git a/01/includes/validation.php b/01/includes/validation.php
new file mode 100644
index 0000000..dd8f86d
--- /dev/null
+++ b/01/includes/validation.php
@@ -0,0 +1,128 @@
+ $maxLength) {
+ return mb_substr($value, 0, $maxLength, 'UTF-8');
+ }
+ return $value;
+}
+
+function validate_enum(string $value, array $allowed): ?string
+{
+ return in_array($value, $allowed, true) ? $value : null;
+}
+
+function validate_lat_lng($lat, $lng): ?array
+{
+ if (!is_numeric($lat) || !is_numeric($lng)) {
+ return null;
+ }
+
+ $lat = (float)$lat;
+ $lng = (float)$lng;
+ if ($lat < MAP_MIN_LAT || $lat > MAP_MAX_LAT || $lng < MAP_MIN_LNG || $lng > MAP_MAX_LNG) {
+ return null;
+ }
+
+ return [$lat, $lng];
+}
+
+function decode_geojson(string $geojson): ?array
+{
+ if ($geojson === '' || strlen($geojson) > MAX_GEOJSON_BYTES) {
+ return null;
+ }
+
+ $decoded = json_decode($geojson, true);
+ return json_last_error() === JSON_ERROR_NONE && is_array($decoded) ? $decoded : null;
+}
+
+function count_coordinates($coordinates): int
+{
+ if (!is_array($coordinates)) {
+ return 0;
+ }
+
+ if (count($coordinates) >= 2 && is_numeric($coordinates[0]) && is_numeric($coordinates[1])) {
+ return 1;
+ }
+
+ $total = 0;
+ foreach ($coordinates as $child) {
+ $total += count_coordinates($child);
+ if ($total > MAX_COORDINATE_POINTS) {
+ return $total;
+ }
+ }
+
+ return $total;
+}
+
+function coordinates_in_bounds($coordinates): bool
+{
+ if (!is_array($coordinates)) {
+ return false;
+ }
+
+ if (count($coordinates) >= 2 && is_numeric($coordinates[0]) && is_numeric($coordinates[1])) {
+ return validate_lat_lng($coordinates[1], $coordinates[0]) !== null;
+ }
+
+ foreach ($coordinates as $child) {
+ if (!coordinates_in_bounds($child)) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function validate_geojson_geometry(array $geometry, array $allowedTypes): bool
+{
+ $type = $geometry['type'] ?? '';
+ if (!in_array($type, $allowedTypes, true)) {
+ return false;
+ }
+ if (!array_key_exists('coordinates', $geometry)) {
+ return false;
+ }
+ if (count_coordinates($geometry['coordinates']) > MAX_COORDINATE_POINTS) {
+ return false;
+ }
+ return coordinates_in_bounds($geometry['coordinates']);
+}
+
+function validate_feature_collection(array $geojson, string $attributeKey): bool
+{
+ if (($geojson['type'] ?? '') !== 'FeatureCollection') {
+ return false;
+ }
+
+ $features = $geojson['features'] ?? null;
+ if (!is_array($features) || count($features) === 0 || count($features) > 1000) {
+ return false;
+ }
+
+ foreach ($features as $feature) {
+ if (($feature['type'] ?? '') !== 'Feature') {
+ return false;
+ }
+ if (!array_key_exists($attributeKey, $feature['properties'] ?? [])) {
+ return false;
+ }
+ if (!validate_geojson_geometry($feature['geometry'] ?? [], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString', 'Point', 'MultiPoint'])) {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/01/index.html b/01/index.html
index 4e8a349..21ce045 100644
--- a/01/index.html
+++ b/01/index.html
@@ -725,7 +725,7 @@
@@ -1011,7 +1011,7 @@
${waLink}
`;
@@ -1075,7 +1075,7 @@
`;
@@ -1114,7 +1114,7 @@
}
btn.disabled = true;
- btn.textContent = '⏳ Menyimpan...';
+ btn.textContent = 'Menyimpan...';
status.textContent = '';
const formData = new FormData();
@@ -1148,7 +1148,7 @@
status.textContent = '✕ ' + err.message;
status.className = 'form-status error';
btn.disabled = false;
- btn.textContent = '💾 Simpan Lokasi';
+ btn.textContent = 'Simpan Lokasi';
});
}
@@ -1158,7 +1158,7 @@
if (!confirmed) return;
btnEl.disabled = true;
- btnEl.textContent = '⏳ Menghapus...';
+ btnEl.textContent = 'Menghapus...';
const formData = new FormData();
formData.append('id', id);
@@ -1196,7 +1196,7 @@
.catch((err) => {
showToast(err.message || 'Gagal terhubung ke server.', 'error');
btnEl.disabled = false;
- btnEl.textContent = '🗑 Hapus Lokasi';
+ btnEl.textContent = 'Hapus Lokasi';
});
}
diff --git a/01/index.php b/01/index.php
index 0099773..3265b06 100644
--- a/01/index.php
+++ b/01/index.php
@@ -22,28 +22,28 @@ $admin_init = $is_admin ? mb_strtoupper(mb_substr($admin_username, 0, 1, 'UT
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--sb-w: 260px;
- --sb-bg: #1c1612;
- --sb-text: #a89f96;
- --sb-text-h: #ffffff;
- --sb-sect: #5c5148;
- --hd-bg: #fafaf9;
- --hd-border: #ddd8d2;
- --body-bg: #f5f0eb;
- --card-bg: #fafaf9;
- --card-border: #ddd8d2;
- --text-1: #201515;
- --text-2: #3d3530;
- --text-m: #7a7067;
- --accent: #0d7490;
- --accent-h: #0a5f7a;
- --accent-dim: rgba(13,116,144,.08);
+ --sb-bg: #201515; /* ink */
+ --sb-text: #939084; /* body-mid */
+ --sb-text-h: #fffefb; /* canvas */
+ --sb-sect: #c5c0b1; /* mute */
+ --hd-bg: #fffefb; /* canvas */
+ --hd-border: #e6e1d5; /* warm border */
+ --body-bg: #f8f4f0; /* canvas-soft */
+ --card-bg: #fffefb; /* canvas */
+ --card-border: #e6e1d5; /* warm border */
+ --text-1: #201515; /* ink */
+ --text-2: #2f2a26; /* ink-soft */
+ --text-m: #605d52; /* body */
+ --accent: #ff4f00; /* primary (Zapier Orange) */
+ --accent-h: #e04600; /* accent hover */
+ --accent-dim: rgba(255, 79, 0, 0.08);
--danger: #ef4444;
- --danger-dim: rgba(239,68,68,.08);
- --danger-bdr: rgba(239,68,68,.25);
- --radius: 12px;
- --radius-sm: 8px;
+ --danger-dim: rgba(239, 68, 68, 0.08);
+ --danger-bdr: rgba(239, 68, 68, 0.25);
+ --radius: 12px; /* rounded.md */
+ --radius-sm: 6px; /* rounded.sm */
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
- --shadow: 0 4px 16px rgba(32,21,21,.1);
+ --shadow: 0 6px 20px rgba(32, 21, 21, 0.08);
}
html, body { height: 100%; font-family: var(--font); background: var(--body-bg); color: var(--text-1); overflow: hidden; }
@@ -293,12 +293,12 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);
.form-group select,
.form-group textarea {
width: 100%; padding: 10px 13px;
- background: #ede8e2; border: 1.5px solid var(--card-border); border-radius: 8px;
+ background: var(--body-bg); border: 1.5px solid var(--card-border); border-radius: var(--radius-sm);
color: var(--text-1); font-family: var(--font); font-size: 13px; outline: none;
transition: border-color .2s, background .2s, box-shadow .2s;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus {
- border-color: var(--accent); background: #fafaf9; box-shadow: 0 0 0 3px rgba(13,116,144,.08);
+ border-color: var(--text-1); background: var(--card-bg); box-shadow: 0 0 0 3px var(--accent-dim);
}
.form-group textarea { resize: vertical; min-height: 64px; }
.form-group input[type="file"] { padding: 8px 13px; cursor: pointer; }
@@ -426,6 +426,201 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);
}
.classic-row:hover { background: rgba(255,255,255,.05); }
.classic-count { font-size: 10px; color: var(--sb-text); margin-top: 1px; }
+
+/* ── Leaflet Popup Customizations ───────────────────────────── */
+.leaflet-popup-content-wrapper {
+ background: var(--card-bg) !important;
+ color: var(--text-1) !important;
+ border: 1px solid var(--card-border);
+ border-radius: var(--radius) !important;
+ box-shadow: var(--shadow) !important;
+ overflow: hidden;
+}
+.leaflet-popup-content {
+ margin: 0 !important;
+ font-family: var(--font) !important;
+}
+.leaflet-popup-tip {
+ background: var(--card-bg) !important;
+ border: 1px solid var(--card-border);
+ box-shadow: none !important;
+}
+
+/* ── Form Popup ─────────────────────────────────────────────── */
+.form-popup {
+ padding: 16px 20px;
+ background: var(--card-bg);
+}
+.form-popup-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--card-border);
+}
+.form-popup-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-sm);
+ background: var(--accent-dim);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+.form-popup-icon.blue {
+ background: rgba(56, 139, 253, 0.12);
+ color: #388bfd;
+}
+.form-popup-icon.amber {
+ background: rgba(234, 179, 8, 0.15);
+ color: #a87c00;
+}
+.form-popup-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-1);
+ letter-spacing: -0.3px;
+}
+.form-popup-coords {
+ font-size: 10px;
+ color: var(--text-m);
+ font-family: var(--font);
+ margin-top: 2px;
+}
+.input-readonly {
+ width: 100%;
+ padding: 10px 13px;
+ background: var(--body-bg);
+ border: 1.5px solid var(--card-border);
+ border-radius: var(--radius-sm);
+ font-size: 13px;
+ color: var(--text-m);
+ font-weight: 500;
+}
+.btn-save {
+ width: 100%;
+ padding: 11px;
+ background: var(--accent);
+ color: var(--sb-text-h);
+ border: none;
+ border-radius: var(--radius);
+ font-family: var(--font);
+ font-size: 13px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s, transform 0.1s;
+ margin-top: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+.btn-save:hover:not(:disabled) {
+ background: var(--accent-h);
+}
+.btn-save:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ── Info Popup ─────────────────────────────────────────────── */
+.info-popup {
+ padding: 16px 20px;
+}
+.info-popup-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+.info-popup-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: var(--radius-sm);
+ background: var(--accent-dim);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ flex-shrink: 0;
+}
+.info-popup-name {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--text-1);
+ letter-spacing: -0.3px;
+ line-height: 1.3;
+}
+.info-popup-id {
+ font-size: 10px;
+ color: var(--text-m);
+ font-family: var(--font);
+ margin-top: 2px;
+}
+.info-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 0;
+ border-top: 1px solid var(--card-border);
+ font-size: 13px;
+}
+.info-row-icon {
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ background: var(--body-bg);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+}
+.info-row-label {
+ font-size: 10px;
+ color: var(--text-m);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+.info-row-value {
+ font-weight: 500;
+ color: var(--text-1);
+}
+.status-badge-inline {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 8px;
+ border-radius: 9999px;
+ font-size: 11px;
+ font-weight: 600;
+}
+.btn-hapus {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ width: 100%;
+ margin-top: 12px;
+ padding: 9px;
+ background: transparent;
+ color: var(--danger);
+ border: 1.5px solid var(--danger-bdr);
+ border-radius: var(--radius);
+ font-family: var(--font);
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.btn-hapus:hover {
+ background: var(--danger-dim);
+ border-color: var(--danger);
+}
@@ -680,6 +875,7 @@ html, body { height: 100%; font-family: var(--font); background: var(--body-bg);