Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c74c7d51a3 | ||
|
|
06a6eb23f2 | ||
|
|
81d6e79e90 | ||
|
|
31e8f46e03 | ||
|
|
e2ebc079f1 | ||
|
|
19096b36e3 | ||
|
|
2658efd89d | ||
|
|
f66c3e2b7f | ||
|
|
de120f08a9 | ||
|
|
5361c3a1c1 | ||
|
|
094c47c15d | ||
|
|
0f7affc498 | ||
|
|
a2888810ad | ||
|
|
8fcb50e16a | ||
|
|
b72dbf3e55 | ||
|
|
6f6960ce42 | ||
|
|
fd46731a53 | ||
|
|
bf25260d4c | ||
|
|
973d9e3a14 | ||
|
|
848dd49cce | ||
|
|
ad62aab07d | ||
|
|
fadbf0084b | ||
|
|
8a312485a7 | ||
|
|
280a8ac9a6 | ||
|
|
1e3067989a | ||
|
|
7db4cb81a3 | ||
|
|
310e91ed97 | ||
|
|
f8e6e86619 | ||
|
|
b49822b553 | ||
|
|
b98ad7568d | ||
|
|
7a5b488882 | ||
|
|
e28c9debea | ||
|
|
861b8f6549 | ||
|
|
19db67138b | ||
|
|
7bb4ca1066 | ||
|
|
d68cc1d664 | ||
|
|
a78e6b766a | ||
|
|
fa791cd3cb | ||
|
|
34d4563a5f | ||
|
|
b34e5a34d8 | ||
|
|
bc7f2bdb09 | ||
|
|
8231058f79 | ||
|
|
fe7682fa45 | ||
|
|
7d77dd1424 | ||
|
|
eef690b809 | ||
|
|
c8b1335ff4 | ||
|
|
c906bca459 | ||
|
|
205db53d7e | ||
| 64e321a350 | |||
| 55f8368214 | |||
|
|
0ca85544aa | ||
|
|
f6ab9bbfa7 | ||
|
|
1cca67668e | ||
|
|
b612d5d232 | ||
|
|
5be6582c39 |
17
.env.gowa.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Salin ke .env.gowa lalu isi nilainya. File .env.gowa tidak di-commit.
|
||||||
|
#
|
||||||
|
# Login dashboard GOWA (Basic Auth): user:password
|
||||||
|
GOWA_BASIC_AUTH=admin:ganti-password-kuat
|
||||||
|
|
||||||
|
# Port dan interface dashboard/API GOWA
|
||||||
|
GOWA_PORT=3000
|
||||||
|
GOWA_BIND=0.0.0.0
|
||||||
|
|
||||||
|
# URL webhook OPEN-ISP dari dalam container.
|
||||||
|
# App di host yang sama : http://host.containers.internal:3001/api/webhook/gowa
|
||||||
|
# App di compose network : http://open-isp:3001/api/webhook/gowa
|
||||||
|
GOWA_WEBHOOK_URL=http://host.containers.internal:3001/api/webhook/gowa
|
||||||
|
|
||||||
|
# Samakan dengan setting gowa_webhook_secret di OPEN-ISP.
|
||||||
|
# Buat dengan: openssl rand -hex 32
|
||||||
|
GOWA_WEBHOOK_SECRET=ganti-dengan-hasil-openssl-rand-hex-32
|
||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux-amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: linux-amd64
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
4
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
|
|
||||||
# Environment and Secrets
|
# Environment and Secrets
|
||||||
.env
|
.env
|
||||||
|
.env.gowa
|
||||||
*.env
|
*.env
|
||||||
*.jks
|
*.jks
|
||||||
*.keystore
|
*.keystore
|
||||||
@@ -85,3 +86,6 @@ public/downloads/logo-*
|
|||||||
|
|
||||||
# penanda restore tertunda (runtime)
|
# penanda restore tertunda (runtime)
|
||||||
database/.restore-pending
|
database/.restore-pending
|
||||||
|
|
||||||
|
# penanda update menunggu restart
|
||||||
|
.restart-required
|
||||||
|
|||||||
240
CHANGELOG.md
@@ -2,6 +2,246 @@
|
|||||||
|
|
||||||
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
|
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.6.17] — 2026-11-06 — "security-block"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- **Auto-block brute force MikroTik** (opsional, default nonaktif):
|
||||||
|
- percobaan login gagal berulang dimasukkan ke firewall `BLACKLIST_BRUTEFORCE` selama 1 hari,
|
||||||
|
- rule `drop` untuk chain `input` dan `forward` ditambahkan otomatis,
|
||||||
|
- hotspot brute force juga memblokir MAC penyerang via Hotspot IP-Binding (`type: blocked`).
|
||||||
|
- Ambang percobaan dapat diatur di **Pengaturan → Keamanan**.
|
||||||
|
|
||||||
|
## [0.6.16] — 2026-11-06 — "wifi-sync"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Perubahan SSID dari **Portal Pelanggan, Teknisi, Admin, dan ACS** memakai service yang sama dan menyinkronkan `wifi_ssid` ke data pelanggan.
|
||||||
|
- URL aksi WiFi/reboot di portal teknisi memakai `encodeURIComponent`, aman untuk tag/ID yang mengandung karakter khusus.
|
||||||
|
|
||||||
|
## [0.6.15] — 2026-11-06 — "telegram-safe"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Telegram admin mendukung **beberapa chat ID** (pisah koma/titik-koma/spasi).
|
||||||
|
- Notifikasi admin dipecah otomatis bila pesan terlalu panjang (batas 4000 karakter).
|
||||||
|
- DNS Telegram diarahkan ke **IPv4** untuk menghindari aggregate error IPv6.
|
||||||
|
|
||||||
|
## [0.6.14] — 2026-11-06 — "portal-custom"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- **Kustomisasi Beranda & Portal Pelanggan** dari Pengaturan:
|
||||||
|
- banner pengumuman (info/warning/danger/success),
|
||||||
|
- status jaringan + keterangan,
|
||||||
|
- badge, judul, dan deskripsi hero,
|
||||||
|
- link Google Maps, Instagram, Facebook, TikTok di footer.
|
||||||
|
|
||||||
|
## [0.6.13] — 2026-11-06 — "webhook-shopeepay"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Parser notifikasi pembayaran mendukung variasi **ShopeePay / Mitra Shopee** yang memakai kata “ditambahkan” / “telah ditambahkan”, tanpa mengubah aplikasi Android.
|
||||||
|
|
||||||
|
## [0.6.12] — 2026-11-06 — "acs-virtual"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Ekstraksi **GenieACS Pro** memakai virtual parameters: `pppoeIP`, `pppIP`, `WanIP`, `IPTR069`, `pppoeUsername2`, `PPPoEUser`, `WlanSSID`.
|
||||||
|
- Lookup perangkat via PPPoE mencakup variabel virtual tersebut, sehingga IP/username lebih cepat dan akurat tampil di tabel ACS.
|
||||||
|
|
||||||
|
## [0.6.11] — 2026-11-06 — "acs-wifi-modal"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Tombol **Atur WiFi (SSID / Sandi)** di daftar perangkat ACS.
|
||||||
|
- Modal menampilkan SSID 2.4G/5G saat ini, simpan SSID dan sandi terpisah.
|
||||||
|
- Endpoint `POST /admin/acs/api/device/:id/ssid` dan `/password`.
|
||||||
|
|
||||||
|
## [0.6.10] — 2026-11-06 — "acs-lookup"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Lookup perangkat GenieACS kini punya fallback lewat data pelanggan (ID, nomor HP, PPPoE, tag ACS, MAC), sehingga perangkat lebih cepat ditemukan dan dashboard tidak menggantung saat tag/ID tidak cocok.
|
||||||
|
|
||||||
|
## [0.6.9] — 2026-11-06 — "acs-wifi-rx"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- **ACS/GenieACS WiFi**: SSID hanya mengubah SSID1 (2.4G) dan SSID5 (5G bila tersedia); tidak lagi menyentuh SSID2/Guest.
|
||||||
|
- `setParameterValues` dipecah per parameter agar tidak kena TR-069 fault 9007.
|
||||||
|
- Sinkronisasi nama WiFi baru ke data pelanggan setelah update.
|
||||||
|
- **RX Power**: dukung nilai negatif, konversi raw CTC EPON/GPON, dan tampilkan **LOS** saat sinyal putus.
|
||||||
|
|
||||||
|
## [0.6.8] — 2026-11-06 — "isolir-url-setting"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Pengaturan **URL Halaman Isolir** (`isolir_portal_url`) dan **Port Redirector Isolir** (`isolir_redirect_port`) di Info Aplikasi.
|
||||||
|
- Redirector isolir membaca URL dari pengaturan **setiap request**, jadi perubahan URL langsung berlaku tanpa restart.
|
||||||
|
- Generator script cloud memakai URL halaman isolir dari pengaturan tersebut.
|
||||||
|
|
||||||
|
## [0.6.7] — 2026-11-06 — "isolir-cloud"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Tab **Cloud (Sederhana)** pada modal Script Isolir (Manajemen Router): generate script RouterOS siap tempel untuk billing yang berjalan di cloud.
|
||||||
|
- Input domain/IP publik billing, port redirector, dan profil PPPoE isolir.
|
||||||
|
- Berisi: address-list BILLING (semua hasil resolve A), hook profil PPPoE, redirect DNS, NAT HTTP ke redirector, serta filter allow/reject/drop.
|
||||||
|
- Deteksi Cloudflare/CDN + peringatan bila port redirector tidak ikut diproxy.
|
||||||
|
- Endpoint `GET /admin/api/isolir-cloud-script`.
|
||||||
|
|
||||||
|
## [0.6.6] — 2026-11-06 — "qr-custom-bulk"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- **Filter tanggal** (dari/sampai) pada histori QR-Custom Payment.
|
||||||
|
- **Pilih semua / per baris** + tombol **Hapus Terpilih** (batch remove) dengan password admin.
|
||||||
|
- **Reset Histori** tetap tersedia untuk menghapus semua data.
|
||||||
|
|
||||||
|
## [0.6.5] — 2026-11-06 — "qr-custom-delete"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- **Hapus satu QR custom** dari detail (wajib password admin).
|
||||||
|
- **Reset seluruh histori QR custom** dari header tabel (wajib password admin, konfirmasi).
|
||||||
|
|
||||||
|
## [0.6.4] — 2026-11-06 — "qr-custom-ux"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- QR pada link publik kini **center**.
|
||||||
|
- Timer hanya tampil saat QR masih aktif; berhenti/disembunyikan ketika LUNAS, ditolak, atau kedaluwarsa.
|
||||||
|
- **Reject / Refund** kini tersedia juga untuk pembayaran yang sudah LUNAS.
|
||||||
|
- QR kedaluwarsa: **nominal dan gambar QR disembunyikan** di link publik maupun detail dashboard.
|
||||||
|
- Tombol **Regenerate** untuk membuat QR baru + kode unik baru dari QR yang kedaluwarsa (juga tersedia di tabel histori).
|
||||||
|
|
||||||
|
## [0.6.3] — 2026-11-06 — "webhook-secret-auto"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- **Secret Token webhook pembayaran** dibuat otomatis (64 hex) bila kosong/default, ditampilkan di Pengaturan dan langsung dipakai pada URL webhook.
|
||||||
|
- URL webhook memakai **Public Base URL**; bila kosong memakai host saat ini (tidak lagi menghasilkan URL relatif).
|
||||||
|
- Default lama `billing-rtrw-secret-key` tidak lagi diterima — forwarder harus memakai secret dari Pengaturan.
|
||||||
|
|
||||||
|
## [0.6.2] — 2026-11-06 — "webhook-diag"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Respons webhook pembayaran kini menyertakan `matched_custom_qr_id` untuk memudahkan diagnosis QR-Custom Payment.
|
||||||
|
|
||||||
|
## [0.6.1] — 2026-11-06 — "qr-custom-gate"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Ikon menu **QR-Custom Payment** memakai ikon yang tersedia (`bi-qr-code`).
|
||||||
|
- Fitur QR-Custom **dinonaktifkan otomatis** bila QRIS Statis (Semi-Otomatis) belum aktif atau `qris_static_payload` kosong:
|
||||||
|
- menu disembunyikan,
|
||||||
|
- form pembuatan dinonaktifkan,
|
||||||
|
- request pembuatan ditolak (tidak ada data/list dibuat tanpa QR).
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-11-06 — "qr-custom"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- **QR-Custom Payment** (menu baru di sidebar UTAMA): membuat QRIS nominal bebas dengan kode unik untuk kebutuhan di luar billing (sewa, donasi perusahaan, dll).
|
||||||
|
- Durasi 20 menit per QR, link share publik, QR dinamis, timer, dan halaman status.
|
||||||
|
- Webhook pembayaran mencocokkan nominal unik; pembayaran dalam 20 menit → LUNAS + notifikasi admin.
|
||||||
|
- Pembayaran dengan nominal sama **setelah 20 menit** → status `warning` + notifikasi admin, dapat diselesaikan atau ditolak/refund.
|
||||||
|
- Panel histori, statistik masuk/pending/warning/rejected, manual pay bila sinkron webhook gagal.
|
||||||
|
- **Mode Webhook Secret GOWA**: pilihan **Auto Generate** (kolom terkunci, tombol simpan nonaktif) atau **Manual** (isi secret → Simpan → terkunci, tombol Ubah untuk mengganti), plus tombol salin.
|
||||||
|
|
||||||
|
## [0.5.20] — 2026-11-06 — "copy-secret"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Tombol **salin** di samping **Webhook Secret** GOWA (sama seperti tombol salin Webhook URL), dengan umpan balik ikon centang.
|
||||||
|
|
||||||
|
## [0.5.19] — 2026-11-06 — "restart-notice"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Setelah update sukses, aplikasi menulis penanda `.restart-required`.
|
||||||
|
- **Floating notification** di semua halaman admin: "Billing perlu restart" berisi versi yang sudah diterapkan dan tautan cara restart.
|
||||||
|
- Banner di halaman Update menjelaskan bahwa versi sudah terbaru tetapi kode lama masih berjalan sampai restart.
|
||||||
|
- Penanda otomatis dihapus saat proses aplikasi baru start, jadi notifikasi hilang setelah restart.
|
||||||
|
|
||||||
|
## [0.5.18] — 2026-11-06 — "gowa-autodevice"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- **Device ID kosong** = otomatis mengikuti device yang terhubung lewat QR dashboard (tidak lagi dipin/disimpan otomatis). Isi Device ID hanya untuk menunjuk device GOWA tertentu.
|
||||||
|
- **Webhook secret GOWA** dibuat otomatis (32 byte hex) bila kosong atau masih default `secret`, lalu ditampilkan di pengaturan untuk disamakan dengan `WHATSAPP_WEBHOOK_SECRET` di GOWA.
|
||||||
|
|
||||||
|
## [0.5.17] — 2026-11-06 — "gowa-connected"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Status GOWA membaca array `/devices` (banyak device) sehingga tidak lagi selalu menampilkan **Belum Terhubung** ketika Device ID belum diisi.
|
||||||
|
- Device yang terhubung disimpan otomatis sebagai `gowa_device_id` — tidak perlu menyalin ID manual dari UI GOWA.
|
||||||
|
- Setelah scan QR, halaman memantau status tiap 3 detik; QR otomatis disembunyikan dan status berubah **Terhubung** saat berhasil.
|
||||||
|
- Pengiriman memilih device yang benar-benar terhubung bila ada lebih dari satu device.
|
||||||
|
|
||||||
|
## [0.5.16] — 2026-11-05 — "restart-help"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Kotak panduan **Cara Restart Aplikasi** di halaman Update: self-hosted (PM2, manual, quadlet/systemd, Docker/Podman Compose) dan cloud/managed (restart otomatis pada update berikutnya atau hubungi admin server).
|
||||||
|
|
||||||
|
## [0.5.15] — 2026-11-05 — "update-keep-env"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Update aplikasi tidak lagi menghapus `.env.gowa` (kredensial/webhook GOWA) saat `git clean`.
|
||||||
|
- Log update tidak lagi menampilkan error `pathspec 'settings.json'/'database'` — file yang tidak dilacak git tidak di-checkout.
|
||||||
|
- `.env.gowa` ditambahkan ke `.gitignore`.
|
||||||
|
|
||||||
|
## [0.5.14] — 2026-11-05 — "ai-test"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Tombol **Test API** di Pengaturan → AI: memvalidasi provider, API key, base URL, dan model yang sedang diisi **tanpa harus menyimpan**. Menampilkan status valid/tidak, HTTP status, dan latensi.
|
||||||
|
|
||||||
|
## [0.5.13] — 2026-11-05 — "lunas-notify"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- Notifikasi WA **LUNAS** memakai gateway aktif (GOWA/Fonnte/Meta/Baileys), bukan hanya Baileys.
|
||||||
|
- Deteksi koneksi GOWA membaca status device dari `/devices` (array) — sebelumnya dianggap tidak terhubung bila Device ID kosong, sehingga notifikasi lunas dilewati.
|
||||||
|
- Approve pembayaran kolektor dari APK admin dan pencocokan notifikasi pembayaran manual kini mengirim WA lunas ke pelanggan.
|
||||||
|
|
||||||
|
## [0.5.12] — 2026-11-05 — "gowa-test-unsaved"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- **Test Koneksi GOWA** kini memakai nilai yang sedang diisi di form (base URL, username, password), jadi tidak perlu simpan pengaturan dulu.
|
||||||
|
|
||||||
|
## [0.5.11] — 2026-11-05 — "gowa-test"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Tombol **Test Koneksi** di Admin → WhatsApp → GOWA: menampilkan apakah GOWA terjangkau, status `/health`, kecocokan username/password Basic Auth, dan jumlah device terdaftar.
|
||||||
|
|
||||||
|
## [0.5.10] — 2026-11-05 — "gowa-mode"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- Pilihan **Mode Akses GOWA** di Admin → WhatsApp: **Cloud (HTTPS)** atau **Local (HTTP)**. Mode Local menampilkan peringatan wajib polyfill `crypto.randomUUID` beserta tautan panduan wiki.
|
||||||
|
- Halaman wiki **GOWA**: setup Compose, perbedaan Cloud/Local, perintah polyfill, setting OPEN-ISP, dan troubleshooting QR/webhook.
|
||||||
|
|
||||||
|
## [0.5.9] — 2026-11-05 — "gowa-qr-image"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- QR GOWA dikirim sebagai **data URI** sehingga gambar tetap tampil walau app berjalan multi-proses atau request gambar terpisah gagal.
|
||||||
|
- `qr_link` dari GOWA yang menunjuk host internal (`127.0.0.1`/container) ditulis ulang memakai base URL publik yang dikonfigurasi.
|
||||||
|
- Log peringatan `[GOWA] Gagal ambil gambar QR` bila pengambilan gambar gagal.
|
||||||
|
|
||||||
|
## [0.5.8] — 2026-11-05 — "gowa-qr-error"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- QR GOWA: pesan gagal kini menampilkan status HTTP dan respons GOWA (mis. `HTTP 401 — Unauthorized`) sehingga penyebabnya langsung terlihat, bukan lagi "pastikan GOWA berjalan".
|
||||||
|
|
||||||
|
## [0.5.7] — 2026-11-05 — "gowa-podman"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- `docker-compose.gowa.yml` + `.env.gowa.example`: menjalankan **GOWA** (WhatsApp gateway + dashboard/UI) dengan Docker atau Podman Compose, termasuk webhook ke OPEN-ISP dan Basic Auth.
|
||||||
|
|
||||||
|
## [0.5.6] — 2026-11-05 — "qris-hangus"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- **QRIS mengikuti skema hangus**: nominal unik hanya untuk **satu tagihan** (tagihan terbaru). Tagihan lama otomatis `void` saat tagihan baru terbit, jadi tidak ada tunggakan menumpuk.
|
||||||
|
- Kirim tagihan QRIS manual memakai tagihan **terbaru** (sebelumnya bisa mengambil tagihan lama).
|
||||||
|
|
||||||
|
### Catatan
|
||||||
|
- Nonaktif otomatis setelah **3 bulan** menunggak dan hapus akun pada bulan berikutnya tetap berjalan sesuai pengaturan `auto_deactivate_months` / `auto_delete_overdue_enabled`.
|
||||||
|
|
||||||
|
## [0.5.5] — 2026-11-05 — "qris-fix"
|
||||||
|
|
||||||
|
### Diperbaiki
|
||||||
|
- **Kode unik bentrok**: pengecekan nominal unik sekarang mencakup invoice, voucher, dan donasi sekaligus — mencegah pembayaran dianggap ambigu oleh webhook (dulu bisa tidak diproses sama sekali).
|
||||||
|
- **Kirim tagihan QRIS manual**: gambar QR benar-benar terlampir (path `qrcode` tidak lagi hardcoded ke `/opt/billing-rtrw`), nominal di pesan memakai nominal unik, dan placeholder `{{qris_kode}}` didukung.
|
||||||
|
- **Kirim tagihan manual** memilih tagihan paling lama lebih dulu (sebelumnya tagihan terbaru).
|
||||||
|
- Pesan **pengingat manual** kini jujur saat tidak dikirim (WA/notifikasi OFF, gateway belum siap, atau tidak ada target hari ini), bukan “dijalankan: target 0”.
|
||||||
|
|
||||||
|
## [0.5.4] — 2026-11-05 — "wa-notice"
|
||||||
|
|
||||||
|
### Ditambahkan
|
||||||
|
- **WA ke pelanggan saat tiket gangguan dibuat** (web & aplikasi): konfirmasi laporan diterima beserta nomor tiket `#TCK-…`.
|
||||||
|
- **Notifikasi WA pada Status Layanan**: centang "Kirim notifikasi WhatsApp" saat mode berubah ke **Gangguan Masal**/**Perbaikan**, dengan target semua pelanggan aktif, per area, atau per router.
|
||||||
|
- **WA "Layanan Kembali Normal"** otomatis saat mode dikembalikan ke NORMAL.
|
||||||
|
- Pengiriman broadcast berjalan di latar belakang dengan jeda acak mengikuti `whatsapp_broadcast_delay`, nomor duplikat digabung, dan hasilnya dicatat di log (`[NoticeWA]`).
|
||||||
|
|
||||||
## [0.5.3] — 2026-11-05 — "forge-lime"
|
## [0.5.3] — 2026-11-05 — "forge-lime"
|
||||||
|
|
||||||
### Ditambahkan
|
### Ditambahkan
|
||||||
|
|||||||
58
README.md
@@ -1,19 +1,21 @@
|
|||||||
> 🪞 **Repositori utama:** [Gitea](https://gitea.oxpoe.xyz/oxpoe/open-isp) · [Forgejo](https://forgejo.oxpoe.xyz/oxpoe/open-isp) — rilis & unduhan ada di sana. GitHub hanya cermin kode.
|
> 🪞 **Repositori utama:** [Gitea](https://gitea.oxpoe.xyz/oxpoe/open-isp) — rilis & unduhan ada di sana. **Forgejo** (forgejo.oxpoe.xyz) hanya **mirror read-only**.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">
|
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<b>Sistem billing & manajemen ISP (fiber optic) — open source, self-hosted.</b><br>
|
<b>Sistem billing & manajemen ISP (fiber optic) — open source, self-hosted builtin AI.</b><br>
|
||||||
PWA pelanggan · Panel admin · Portal teknisi & kolektor · Multi-OLT · RADIUS · WhatsApp/Telegram · QRIS
|
PWA pelanggan · Panel admin · Portal teknisi & kolektor · Multi-OLT · RADIUS · WhatsApp/Telegram · QRIS
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
|
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
|
||||||
<img src="https://img.shields.io/badge/versi-0.5.3_forge--lime-ADFF2F" alt="Versi 0.5.3 (forge-lime)">
|
<img src="https://img.shields.io/badge/versi-0.6.17_security--block-ADFF2F" alt="Versi 0.6.17 (security-block)">
|
||||||
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
|
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
|
||||||
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
|
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
|
||||||
|
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>
|
||||||
|
<a href="https://oxpoe.xyz"><img src="https://img.shields.io/badge/donasi-oxpoe.xyz-e5484d?logo=buymeacoffee&logoColor=white" alt="Donasi"></a>
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.stars_count&label=stars&style=flat&color=blue&logo=gitea" alt="Stars">
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.stars_count&label=stars&style=flat&color=blue&logo=gitea" alt="Stars">
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.forks_count&label=forks&style=flat&color=lightgrey&logo=gitea" alt="Forks">
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.forks_count&label=forks&style=flat&color=lightgrey&logo=gitea" alt="Forks">
|
||||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.open_issues_count&label=issues&style=flat&color=orange&logo=gitea" alt="Issues">
|
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.xyz%2Fapi%2Fv1%2Frepos%2Foxpoe%2Fopen-isp&query=%24.open_issues_count&label=issues&style=flat&color=orange&logo=gitea" alt="Issues">
|
||||||
@@ -21,21 +23,24 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**[demo](https://demo-openisp.oxpoe.xyz) · [donate](https://oxpoe.xyz) · [main repo](https://gitea.oxpoe.xyz/oxpoe/open-isp)**
|
||||||
|
|
||||||
|
---
|
||||||
**OPEN-ISP** adalah aplikasi billing dan manajemen ISP yang lengkap: tagihan otomatis, isolir otomatis, pembayaran (QRIS/webhook), perangkat MikroTik (PPPoE & hotspot), OLT PON multi-vendor, TR-069 (GenieACS/ACS), RADIUS, peta jaringan, notifikasi WhatsApp & Telegram, hingga portal self-service pelanggan dalam satu platform — semuanya berjalan mandiri (self-hosted) dengan SQLite sebagai basis data. **NOTE:** dibuat dan ditulis oleh AI ([Deepseek](https://github.com/deepseek-ai)), on [opencode](https://github.com/anomalyco/opencode).
|
**OPEN-ISP** adalah aplikasi billing dan manajemen ISP yang lengkap: tagihan otomatis, isolir otomatis, pembayaran (QRIS/webhook), perangkat MikroTik (PPPoE & hotspot), OLT PON multi-vendor, TR-069 (GenieACS/ACS), RADIUS, peta jaringan, notifikasi WhatsApp & Telegram, hingga portal self-service pelanggan dalam satu platform — semuanya berjalan mandiri (self-hosted) dengan SQLite sebagai basis data. **NOTE:** dibuat dan ditulis oleh AI ([Deepseek](https://github.com/deepseek-ai)), on [opencode](https://github.com/anomalyco/opencode).
|
||||||
|
|
||||||
## Tangkapan Layar
|
## Tangkapan Layar
|
||||||
|
|
||||||
| Dashboard Admin | Daftar Pelanggan |
|
| Dashboard Admin | Daftar Pelanggan |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Inventaris & Stok Keluar | Pengaturan AI & Self-Healing |
|
| Inventaris & Stok Keluar | Pengaturan AI & Self-Healing |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
| Login Pelanggan | Dashboard Pelanggan |
|
| Login Pelanggan | Dashboard Pelanggan |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
Selengkapnya (10 gambar, desktop + mobile): [`docs/screenshots/`](docs/screenshots/).
|
Selengkapnya (10 gambar, desktop + mobile): [`docs/screenshots/`](docs/screenshots/).
|
||||||
|
|
||||||
@@ -63,16 +68,16 @@ Selengkapnya (10 gambar, desktop + mobile): [`docs/screenshots/`](docs/screensho
|
|||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Dashboard Admin</b><br><img src="docs/screenshots/admin-dashboard.jpg" width="420"></td>
|
<td align="center"><b>Dashboard Admin</b><br><img src="docs/screenshots/admin-dashboard.webp" width="420"></td>
|
||||||
<td align="center"><b>Data Pelanggan</b><br><img src="docs/screenshots/admin-customers.jpg" width="420"></td>
|
<td align="center"><b>Data Pelanggan</b><br><img src="docs/screenshots/admin-customers.webp" width="420"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Tagihan & Pembayaran</b><br><img src="docs/screenshots/admin-billing.jpg" width="420"></td>
|
<td align="center"><b>Tagihan & Pembayaran</b><br><img src="docs/screenshots/admin-billing.webp" width="420"></td>
|
||||||
<td align="center"><b>OLT (PON)</b><br><img src="docs/screenshots/admin-olts.jpg" width="420"></td>
|
<td align="center"><b>OLT (PON)</b><br><img src="docs/screenshots/admin-olts.webp" width="420"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center"><b>Portal Pelanggan (PWA)</b><br><img src="docs/screenshots/customer-dashboard.jpg" width="250"></td>
|
<td align="center"><b>Portal Pelanggan (PWA)</b><br><img src="docs/screenshots/customer-dashboard.webp" width="250"></td>
|
||||||
<td align="center"><b>Tagihan Pelanggan</b><br><img src="docs/screenshots/customer-billing.jpg" width="250"></td>
|
<td align="center"><b>Tagihan Pelanggan</b><br><img src="docs/screenshots/customer-billing.webp" width="250"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -80,6 +85,10 @@ Selengkapnya: **[Wiki OPEN-ISP](https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki)**.
|
|||||||
|
|
||||||
## Mulai Cepat
|
## Mulai Cepat
|
||||||
|
|
||||||
|
### Demo online
|
||||||
|
|
||||||
|
Coba tanpa instal: **[demo-openisp.oxpoe.xyz](https://demo-openisp.oxpoe.xyz)** — login `admin` / `demo-isp123`.
|
||||||
|
|
||||||
[](https://codespaces.new/oxpoe/open-isp)
|
[](https://codespaces.new/oxpoe/open-isp)
|
||||||
|
|
||||||
### GitHub Codespaces (demo instan)
|
### GitHub Codespaces (demo instan)
|
||||||
@@ -97,6 +106,25 @@ docker compose up -d
|
|||||||
Buka **http://localhost:3001** — login awal `admin` / `demo-isp123` (**segera ganti password**).
|
Buka **http://localhost:3001** — login awal `admin` / `demo-isp123` (**segera ganti password**).
|
||||||
Data tersimpan di volume `docker-data/` (settings, database, logs, uploads, sesi WhatsApp).
|
Data tersimpan di volume `docker-data/` (settings, database, logs, uploads, sesi WhatsApp).
|
||||||
|
|
||||||
|
### WhatsApp gateway GOWA + UI (Podman / Docker Compose)
|
||||||
|
|
||||||
|
GOWA dijalankan sebagai container terpisah, lengkap dengan dashboard/scan QR bawaan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.gowa.example .env.gowa # isi GOWA_BASIC_AUTH + GOWA_WEBHOOK_SECRET
|
||||||
|
podman compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Buka dashboard di **http://<ip-host>:3000** (login Basic Auth), lalu scan QR.
|
||||||
|
Di OPEN-ISP → **Admin → WhatsApp**: pilih gateway **GOWA**, isi base URL `http://127.0.0.1:3000`
|
||||||
|
(app di host yang sama), username/password, dan **webhook secret** yang sama dengan `.env.gowa`.
|
||||||
|
|
||||||
|
> **UI hanya loading saat diakses lewat `http://IP:3000`?** gowa-ui memakai `crypto.randomUUID`
|
||||||
|
> yang hanya tersedia di secure context (HTTPS atau `localhost`). Pilih salah satu:
|
||||||
|
> pakai HTTPS/reverse proxy, buka lewat SSH tunnel (`ssh -L 3000:127.0.0.1:3000 user@server`
|
||||||
|
> lalu `http://localhost:3000`), atau tambahkan polyfill `crypto.randomUUID` ke
|
||||||
|
> `storages/ui/index.html` dan set `GOWA_UI_AUTO_UPDATE=false`.
|
||||||
|
|
||||||
### Manual (Node.js ≥ 20, disarankan 22 LTS)
|
### Manual (Node.js ≥ 20, disarankan 22 LTS)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -146,8 +174,10 @@ Dirilis dengan lisensi **ISC** — bebas digunakan, dimodifikasi, dan didistribu
|
|||||||
OPEN-ISP tumbuh dari karya **Alijaya Net** — ayo dukung juga pengembang aslinya:
|
OPEN-ISP tumbuh dari karya **Alijaya Net** — ayo dukung juga pengembang aslinya:
|
||||||
|
|
||||||
- **Donasi Alijaya Net**: [app.alijaya.com/donasi](https://app.alijaya.com/donasi) · WhatsApp [0819-4721-5703](https://wa.me/6281947215703)
|
- **Donasi Alijaya Net**: [app.alijaya.com/donasi](https://app.alijaya.com/donasi) · WhatsApp [0819-4721-5703](https://wa.me/6281947215703)
|
||||||
|
- **Demo online**: [demo-openisp.oxpoe.xyz](https://demo-openisp.oxpoe.xyz)
|
||||||
|
- **Donasi OPEN-ISP**: [oxpoe.xyz](https://oxpoe.xyz) — sukarela, untuk pengembangan fitur.
|
||||||
|
|
||||||
Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **menyusul** — sementara ini bisa lewat menu **Dukung OPEN-ISP** di aplikasi.
|
Dukungan juga bisa lewat menu **Dukung OPEN-ISP** di aplikasi.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -155,5 +185,7 @@ Dukungan untuk **pengembangan OPEN-ISP** (agar makin canggih): link donasi **men
|
|||||||
|
|
||||||
**OPEN-ISP** is an open-source ISP billing & management system: customer PWA, admin/technician/collector portals, MikroTik PPPoE & hotspot, multi-vendor OLT (SNMP/CLI), GenieACS TR-069, RADIUS, WhatsApp & Telegram notifications, QRIS payments, and a bilingual (ID/EN) UI.
|
**OPEN-ISP** is an open-source ISP billing & management system: customer PWA, admin/technician/collector portals, MikroTik PPPoE & hotspot, multi-vendor OLT (SNMP/CLI), GenieACS TR-069, RADIUS, WhatsApp & Telegram notifications, QRIS payments, and a bilingual (ID/EN) UI.
|
||||||
|
|
||||||
|
Live demo: **https://demo-openisp.oxpoe.xyz** (`admin` / `demo-isp123`). Donate: **https://oxpoe.xyz**.
|
||||||
|
|
||||||
Quick start: `docker compose up -d` → http://localhost:3001 (default login `admin` / `demo-isp123`).
|
Quick start: `docker compose up -d` → http://localhost:3001 (default login `admin` / `demo-isp123`).
|
||||||
Advanced menus are unlocked with a free activation code — not published in this repository (set via `activation_code` in `settings.json`). License: **ISC**. Based on the base code & repo by **Alijaya Net** ([alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw)). Code by DeepSeek V4.1 Flash — max, on opencode.
|
Advanced menus are unlocked with a free activation code — not published in this repository (set via `activation_code` in `settings.json`). License: **ISC**. Based on the base code & repo by **Alijaya Net** ([alijayanet/billing-rtrw](https://github.com/alijayanet/billing-rtrw)). Code by DeepSeek V4.1 Flash — max, on opencode.
|
||||||
|
|||||||
167
app-customer.js
@@ -60,6 +60,10 @@ const { SUPPORTED_LANGS, FALLBACK_LANG, normalizeLang, t } = require('./config/i
|
|||||||
// Pastikan semua default settings ada (untuk migrasi/update dari Gitea)
|
// Pastikan semua default settings ada (untuk migrasi/update dari Gitea)
|
||||||
if (typeof ensureDefaultSettings === 'function') {
|
if (typeof ensureDefaultSettings === 'function') {
|
||||||
ensureDefaultSettings();
|
ensureDefaultSettings();
|
||||||
|
try { require('./config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
|
||||||
|
try { require('./config/settingsManager').ensureWebhookSecret(); } catch (e) {}
|
||||||
|
// Proses baru = kode terbaru sudah dimuat; hapus penanda restart.
|
||||||
|
try { fs.rmSync(path.join(__dirname, '.restart-required'), { force: true }); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inisialisasi aplikasi Express
|
// Inisialisasi aplikasi Express
|
||||||
@@ -177,7 +181,7 @@ app.use((req, res, next) => {
|
|||||||
res.locals.serviceNotice = noticeSvc.getServiceNotice();
|
res.locals.serviceNotice = noticeSvc.getServiceNotice();
|
||||||
res.locals.uptimeUrl = getSetting('uptime_url', '');
|
res.locals.uptimeUrl = getSetting('uptime_url', '');
|
||||||
res.locals.heroVer = getSetting('landing_hero_version', '');
|
res.locals.heroVer = getSetting('landing_hero_version', '');
|
||||||
res.locals.projectUrl = String(getSetting('project_url', 'https://gitea.oxpoe.qzz.io/oxpoe/open-isp') || '').replace(/\/+$/, '');
|
res.locals.projectUrl = String(getSetting('project_url', 'https://gitea.oxpoe.xyz/oxpoe/open-isp') || '').replace(/\/+$/, '');
|
||||||
res.locals.appVersion = String(getSetting('app_version', '0.5.1') || '0.5.1');
|
res.locals.appVersion = String(getSetting('app_version', '0.5.1') || '0.5.1');
|
||||||
res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
|
res.locals.appVersionName = String(getSetting('app_version_name', 'pickle-lime') || 'pickle-lime');
|
||||||
res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0');
|
res.locals.pwaVersion = String(getSetting('pwa_version', '2.0') || '2.0');
|
||||||
@@ -330,6 +334,17 @@ const updateWebhookPaymentNotifMatchDonation = db.prepare(`
|
|||||||
SET matched_donation_order_id=?
|
SET matched_donation_order_id=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`);
|
`);
|
||||||
|
const selectCustomQrByUniqueAmount = db.prepare(`
|
||||||
|
SELECT id, status, title, amount_unique, expires_at
|
||||||
|
FROM custom_qr_payments
|
||||||
|
WHERE status IN ('pending','expired') AND amount_unique = ?
|
||||||
|
ORDER BY id DESC LIMIT 2
|
||||||
|
`);
|
||||||
|
const updateWebhookPaymentNotifMatchCustomQr = db.prepare(`
|
||||||
|
UPDATE webhook_payment_notifs
|
||||||
|
SET matched_custom_qr_id=?
|
||||||
|
WHERE id=?
|
||||||
|
`);
|
||||||
|
|
||||||
const markInvoicePaidAppendNote = db.prepare(`
|
const markInvoicePaidAppendNote = db.prepare(`
|
||||||
UPDATE invoices
|
UPDATE invoices
|
||||||
@@ -372,7 +387,7 @@ function parseRupiahAmountFromNotification(content) {
|
|||||||
'dana masuk', 'saldo masuk', 'saldo bertambah', 'berhasil top up',
|
'dana masuk', 'saldo masuk', 'saldo bertambah', 'berhasil top up',
|
||||||
'top up berhasil', 'topup berhasil', 'terima uang', 'telah diterima dari',
|
'top up berhasil', 'topup berhasil', 'terima uang', 'telah diterima dari',
|
||||||
'diterima dari', 'qris berhasil', 'qris sukses', 'qr berhasil', 'qr sukses',
|
'diterima dari', 'qris berhasil', 'qris sukses', 'qr berhasil', 'qr sukses',
|
||||||
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan',
|
'payment received', 'kamu menerima', 'berhasil menerima', 'menambahkan', 'ditambahkan', 'telah ditambahkan', 'berhasil ditambahkan',
|
||||||
'telah diterima sebesar', 'uang diterima', 'menerima dana', 'dana diterima', 'menerima sebesar', 'baru saja menerima', 'telah menerima',
|
'telah diterima sebesar', 'uang diterima', 'menerima dana', 'dana diterima', 'menerima sebesar', 'baru saja menerima', 'telah menerima',
|
||||||
'just received', 'you received', 'you have received', 'funds received', 'has been received', 'incoming transfer', 'credited'
|
'just received', 'you received', 'you have received', 'funds received', 'has been received', 'incoming transfer', 'credited'
|
||||||
];
|
];
|
||||||
@@ -395,7 +410,8 @@ function parseRupiahAmountFromNotification(content) {
|
|||||||
'uang masuk', 'telah diterima', 'top up berhasil', 'topup berhasil', 'berhasil top up',
|
'uang masuk', 'telah diterima', 'top up berhasil', 'topup berhasil', 'berhasil top up',
|
||||||
'menerima pembayaran', 'qris statis diterima', 'qris diterima', 'pembayaran qris',
|
'menerima pembayaran', 'qris statis diterima', 'qris diterima', 'pembayaran qris',
|
||||||
'menerima dana', 'dana diterima', 'menerima sebesar', 'telah menerima',
|
'menerima dana', 'dana diterima', 'menerima sebesar', 'telah menerima',
|
||||||
'just received', 'you received', 'you have received', 'funds received', 'has been received'
|
'just received', 'you received', 'you have received', 'funds received', 'has been received',
|
||||||
|
'ditambahkan', 'telah ditambahkan', 'berhasil ditambahkan'
|
||||||
];
|
];
|
||||||
const hasStrongIncoming = strongIncoming.some((hint) => lower.includes(hint));
|
const hasStrongIncoming = strongIncoming.some((hint) => lower.includes(hint));
|
||||||
|
|
||||||
@@ -412,7 +428,7 @@ function parseRupiahAmountFromNotification(content) {
|
|||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
/(?:\bRp\.?\s*|IDR\s*)([0-9][0-9\.\,\s]*)/i,
|
/(?:\bRp\.?\s*|IDR\s*)([0-9][0-9\.\,\s]*)/i,
|
||||||
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
|
/(?:sebesar|senilai|nominal|masuk|transfer|top\s*up|topup|saldo\s+masuk|ditambahkan)\s*(?:saldo\s*)?(?:\bRp\.?\s*)?([0-9][0-9\.\,\s]*)/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
let raw = null;
|
let raw = null;
|
||||||
@@ -628,8 +644,8 @@ async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
|
|||||||
if (!inv) return;
|
if (!inv) return;
|
||||||
const phone = String(inv.customer_phone || '').trim();
|
const phone = String(inv.customer_phone || '').trim();
|
||||||
if (!phone) return;
|
if (!phone) return;
|
||||||
const { sendWA, whatsappStatus } = await import('./services/whatsappBot.mjs');
|
// Pakai gateway aktif (GOWA/Fonnte/Meta/Baileys), bukan hanya Baileys.
|
||||||
if (whatsappStatus.connection !== 'open') throw new Error('Bot WhatsApp belum terhubung');
|
const waNotify = require('./services/waNotifyService');
|
||||||
|
|
||||||
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
|
const appUrl = (settings.public_base_url || '').replace(/\/$/, '');
|
||||||
const portalUrl = appUrl ? `${appUrl}/customer` : '';
|
const portalUrl = appUrl ? `${appUrl}/customer` : '';
|
||||||
@@ -654,7 +670,7 @@ async function trySendWaPaymentSuccess(settings, invoiceId, methodLabel) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`[WEBHOOK][payment-notif] Sending WA success notif to ${phone} inv=${invoiceId} method=${metode}`);
|
logger.info(`[WEBHOOK][payment-notif] Sending WA success notif to ${phone} inv=${invoiceId} method=${metode}`);
|
||||||
await sendWA(phone, msg);
|
await waNotify.sendText(phone, msg);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`[WEBHOOK][payment-notif] WA success notif failed: ${e?.message || e}`);
|
logger.error(`[WEBHOOK][payment-notif] WA success notif failed: ${e?.message || e}`);
|
||||||
}
|
}
|
||||||
@@ -887,7 +903,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
|||||||
gotTrim = gotTrim.replace(/^[\"'\s]+|[\"'\s]+$/g, '');
|
gotTrim = gotTrim.replace(/^[\"'\s]+|[\"'\s]+$/g, '');
|
||||||
if (/^bearer\s+/i.test(gotTrim)) gotTrim = gotTrim.replace(/^bearer\s+/i, '').trim();
|
if (/^bearer\s+/i.test(gotTrim)) gotTrim = gotTrim.replace(/^bearer\s+/i, '').trim();
|
||||||
|
|
||||||
const isSecretValid = (gotTrim === expectedTrim) || (gotTrim === 'billing-rtrw-secret-key');
|
const isSecretValid = gotTrim === expectedTrim;
|
||||||
|
|
||||||
if (!isSecretValid) {
|
if (!isSecretValid) {
|
||||||
logger.warn(`[WEBHOOK][payment-notif] Forbidden: secret_key mismatch. service=${String(service || '-')} (gotLen=${gotTrim.length}${gotTrim ? ', ***' + gotTrim.slice(-3) : ', empty'}, expectedLen=${expectedTrim.length}, ***${expectedTrim.slice(-3)})`);
|
logger.warn(`[WEBHOOK][payment-notif] Forbidden: secret_key mismatch. service=${String(service || '-')} (gotLen=${gotTrim.length}${gotTrim ? ', ***' + gotTrim.slice(-3) : ', empty'}, expectedLen=${expectedTrim.length}, ***${expectedTrim.slice(-3)})`);
|
||||||
@@ -995,14 +1011,17 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
|||||||
let matchedInvoiceId = null;
|
let matchedInvoiceId = null;
|
||||||
let matchedVoucherOrderId = null;
|
let matchedVoucherOrderId = null;
|
||||||
let matchedDonationOrderId = null;
|
let matchedDonationOrderId = null;
|
||||||
|
let matchedCustomQrId = null;
|
||||||
if (amount != null) {
|
if (amount != null) {
|
||||||
try {
|
try {
|
||||||
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
|
const invCandidates = selectInvoiceByUniqueAmount.all(amount);
|
||||||
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
|
const vCandidates = selectVoucherOrderByUniqueAmount.all(amount);
|
||||||
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
|
const dCandidates = selectDonationOrderByUniqueAmount.all(amount);
|
||||||
|
const cqCandidates = selectCustomQrByUniqueAmount.all(amount);
|
||||||
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
|
const totalCandidates = (Array.isArray(invCandidates) ? invCandidates.length : 0) +
|
||||||
(Array.isArray(vCandidates) ? vCandidates.length : 0) +
|
(Array.isArray(vCandidates) ? vCandidates.length : 0) +
|
||||||
(Array.isArray(dCandidates) ? dCandidates.length : 0);
|
(Array.isArray(dCandidates) ? dCandidates.length : 0) +
|
||||||
|
(Array.isArray(cqCandidates) ? cqCandidates.length : 0);
|
||||||
|
|
||||||
if (totalCandidates === 1) {
|
if (totalCandidates === 1) {
|
||||||
if (Array.isArray(invCandidates) && invCandidates.length === 1) {
|
if (Array.isArray(invCandidates) && invCandidates.length === 1) {
|
||||||
@@ -1066,17 +1085,36 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
|||||||
logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`);
|
logger.error(`[WEBHOOK][payment-notif] Donation fulfill error: ${e?.message || e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (Array.isArray(cqCandidates) && cqCandidates.length === 1) {
|
||||||
|
const cq = cqCandidates[0];
|
||||||
|
const cqId = Number(cq.id || 0);
|
||||||
|
if (cqId > 0) {
|
||||||
|
const svc = require('./services/customQrService');
|
||||||
|
const expired = svc.isExpired(cq);
|
||||||
|
if (String(cq.status) === 'pending' && !expired) {
|
||||||
|
svc.markPaidFromWebhook(cqId, notifId || null);
|
||||||
|
logger.info(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount}`);
|
||||||
|
} else {
|
||||||
|
svc.markWarningFromWebhook(cqId, notifId || null);
|
||||||
|
logger.warn(`[WEBHOOK][payment-notif] MATCH custom_qr=${cqId} amount=${amount} SETELAH 20 MENIT (warning)`);
|
||||||
|
}
|
||||||
|
matchedCustomQrId = cqId;
|
||||||
|
if (notifId) {
|
||||||
|
try { updateWebhookPaymentNotifMatchCustomQr.run(cqId, notifId); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (totalCandidates > 1) {
|
} else if (totalCandidates > 1) {
|
||||||
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
|
const invIds = Array.isArray(invCandidates) ? invCandidates.map(x => x.id).join(',') : '';
|
||||||
const vIds = Array.isArray(vCandidates) ? vCandidates.map(x => x.id).join(',') : '';
|
const vIds = Array.isArray(vCandidates) ? vCandidates.map(x => x.id).join(',') : '';
|
||||||
const dIds = Array.isArray(dCandidates) ? dCandidates.map(x => x.id).join(',') : '';
|
const dIds = Array.isArray(dCandidates) ? dCandidates.map(x => x.id).join(',') : '';
|
||||||
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}]`);
|
const cqIds = Array.isArray(cqCandidates) ? cqCandidates.map(x => x.id).join(',') : '';
|
||||||
|
logger.error(`[WEBHOOK][payment-notif] MATCH ambiguous: amount=${amount} invoices=[${invIds}] vouchers=[${vIds}] donations=[${dIds}] custom_qr=[${cqIds}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tidak ada tagihan UNPAID dgn nominal itu → kemungkinan notifikasi ke-2 dari app lain
|
// Tidak ada tagihan UNPAID dgn nominal itu → kemungkinan notifikasi ke-2 dari app lain
|
||||||
// (mis. GoPay & GoPay Merchant) untuk pembayaran yang SUDAH LUNAS. Hubungkan ke invoice lunas.
|
// (mis. GoPay & GoPay Merchant) untuk pembayaran yang SUDAH LUNAS. Hubungkan ke invoice lunas.
|
||||||
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId) {
|
if (!matchedInvoiceId && !matchedVoucherOrderId && !matchedDonationOrderId && !matchedCustomQrId) {
|
||||||
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
|
const paidInv = selectPaidInvoiceByUniqueAmount.all(amount);
|
||||||
if (Array.isArray(paidInv) && paidInv.length === 1) {
|
if (Array.isArray(paidInv) && paidInv.length === 1) {
|
||||||
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
|
matchedInvoiceId = Number(paidInv[0].id || 0) || null;
|
||||||
@@ -1094,7 +1132,7 @@ app.post('/api/webhook/v1/payment-notif', multer().any(), async (req, res) => {
|
|||||||
|
|
||||||
if (amount != null) {
|
if (amount != null) {
|
||||||
logger.info(`[WEBHOOK][payment-notif] PARSED service=${String(service || '-')} amount=${amount}`);
|
logger.info(`[WEBHOOK][payment-notif] PARSED service=${String(service || '-')} amount=${amount}`);
|
||||||
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId });
|
return res.status(200).json({ status: 'processed', parsed: true, amount, matched_invoice_id: matchedInvoiceId, matched_voucher_order_id: matchedVoucherOrderId, matched_donation_order_id: matchedDonationOrderId, matched_custom_qr_id: matchedCustomQrId });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error(`[WEBHOOK][payment-notif] FAILED parse: "${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`);
|
logger.error(`[WEBHOOK][payment-notif] FAILED parse: "${rawText.replace(/\r?\n/g, ' ').slice(0, 500)}"`);
|
||||||
@@ -1973,13 +2011,8 @@ function ensureDonationOrderQrisUnique(baseAmount, donorPhone, donorName, notes)
|
|||||||
|
|
||||||
// Halaman Publik Donasi QRIS Statis
|
// Halaman Publik Donasi QRIS Statis
|
||||||
app.get(['/donasi', '/donate', '/public/donasi'], (req, res) => {
|
app.get(['/donasi', '/donate', '/public/donasi'], (req, res) => {
|
||||||
const settings = getSettingsWithCache();
|
// Halaman dukungan dialihkan ke situs eksternal agar bisa dikustomisasi.
|
||||||
res.render('donate', {
|
return res.redirect(302, 'https://oxpoe.xyz');
|
||||||
settings,
|
|
||||||
company: settings.company_header || 'OPEN-ISP',
|
|
||||||
lang: req.session?.lang || 'id',
|
|
||||||
t: res.locals.t || ((k, d) => d)
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Endpoint Buat Pesanan Donasi dengan Kode Unik
|
// Endpoint Buat Pesanan Donasi dengan Kode Unik
|
||||||
@@ -2069,6 +2102,102 @@ app.post(['/donasi/confirm', '/api/donasi/confirm'], async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── QR CUSTOM PAYMENT (publik) ─────────────────────────────────────────────
|
||||||
|
function cqEscape(v) {
|
||||||
|
return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/custom-qr/:token', (req, res) => {
|
||||||
|
let row = null;
|
||||||
|
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
|
||||||
|
if (!row) return res.status(404).send('QR tidak ditemukan atau sudah dihapus.');
|
||||||
|
const settings = getSettingsWithCache();
|
||||||
|
const company = settings.company_header || 'OPEN-ISP';
|
||||||
|
const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' };
|
||||||
|
const status = String(row.status || 'pending');
|
||||||
|
const expiresMs = row.expires_at ? new Date(row.expires_at).getTime() : 0;
|
||||||
|
const expiredNow = status === 'expired' || (status === 'pending' && expiresMs && expiresMs < Date.now());
|
||||||
|
const showAmount = !expiredNow;
|
||||||
|
const showQr = !expiredNow && status !== 'paid' && status !== 'rejected';
|
||||||
|
const showTimer = status === 'pending' && !expiredNow;
|
||||||
|
const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID');
|
||||||
|
const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID');
|
||||||
|
const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${cqEscape(row.title)} — ${cqEscape(company)}</title>
|
||||||
|
<style>
|
||||||
|
body{margin:0;background:#0b1220;color:#e5e7eb;font-family:system-ui,Segoe UI,Arial;display:grid;place-items:center;min-height:100vh;padding:20px}
|
||||||
|
.card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center}
|
||||||
|
h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
|
||||||
|
.amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b}
|
||||||
|
.qr{background:#fff;padding:12px;border-radius:14px;display:block;width:max-content;margin:14px auto}.qr img{display:block;width:240px;height:240px}
|
||||||
|
.badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px}
|
||||||
|
.pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80}
|
||||||
|
.expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1}
|
||||||
|
.timer{font-size:14px;color:#fbbf24;margin-top:8px}.note{font-size:12px;color:#64748b;margin-top:12px}
|
||||||
|
</style></head><body><div class="card">
|
||||||
|
<div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div>
|
||||||
|
<h1>${cqEscape(row.title)}</h1>
|
||||||
|
${showAmount
|
||||||
|
? `<div class="amt">${amountText}</div><div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>`
|
||||||
|
: `<div class="amt" style="font-size:18px;color:#f87171">QR Kedaluwarsa</div><div class="base">Nominal dan QR disembunyikan. Minta admin membuat QR baru.</div>`}
|
||||||
|
<div class="badge ${cqEscape(expiredNow ? 'expired' : status)}" id="statusBadge">${cqEscape(expiredNow ? 'Kedaluwarsa' : (statusLabels[status] || status))}</div>
|
||||||
|
${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''}
|
||||||
|
${showTimer ? `<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>` : ''}
|
||||||
|
${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''}
|
||||||
|
${showAmount ? `<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>` : ''}
|
||||||
|
<div class="note">${cqEscape(company)}</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires');
|
||||||
|
function tick(){
|
||||||
|
if(!el) return;
|
||||||
|
if(!exp) return;
|
||||||
|
var d=new Date(exp).getTime()-Date.now();
|
||||||
|
if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; return; }
|
||||||
|
var m=Math.floor(d/60000), s=Math.floor((d%60000)/1000);
|
||||||
|
el.textContent='Sisa waktu: '+m+' menit '+s+' detik';
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick,1000);
|
||||||
|
var last=${JSON.stringify(status)};
|
||||||
|
setInterval(function(){
|
||||||
|
fetch('/custom-qr/${cqEscape(row.share_token)}/status',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
|
||||||
|
if(d && d.status && d.status!==last){ location.reload(); }
|
||||||
|
}).catch(function(){});
|
||||||
|
},5000);
|
||||||
|
})();
|
||||||
|
</script></body></html>`;
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
return res.send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/custom-qr/:token/qr.png', async (req, res) => {
|
||||||
|
let row = null;
|
||||||
|
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
|
||||||
|
if (!row) return res.status(404).send('QR tidak ditemukan');
|
||||||
|
const settings = getSettingsWithCache();
|
||||||
|
const payload = String(settings.qris_static_payload || '').trim();
|
||||||
|
if (!payload) return res.status(404).send('QRIS statis belum diatur admin.');
|
||||||
|
try {
|
||||||
|
const jpg = await qrisUtil.buildDynamicQrisJpgBuffer(payload, Number(row.amount_unique || row.amount || 0));
|
||||||
|
res.set('Content-Type', 'image/jpeg');
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
return res.send(jpg);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[CustomQR] Gagal membuat QR: ' + e.message);
|
||||||
|
return res.status(500).send('Gagal membuat QR');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/custom-qr/:token/status', (req, res) => {
|
||||||
|
let row = null;
|
||||||
|
try { row = require('./services/customQrService').getByToken(req.params.token); } catch (e) {}
|
||||||
|
if (!row) return res.status(404).json({ status: 'not_found' });
|
||||||
|
return res.json({ status: row.status, paid_at: row.paid_at || null });
|
||||||
|
});
|
||||||
|
|
||||||
// Mount built-in ACS server endpoint (TR-069)
|
// Mount built-in ACS server endpoint (TR-069)
|
||||||
const acsServerService = require('./services/acsServerService');
|
const acsServerService = require('./services/acsServerService');
|
||||||
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);
|
app.post('/acs', express.raw({ type: ['text/xml', 'application/soap+xml', 'application/xml', 'text/plain'], limit: '2mb' }), acsServerService.handleCwmpRequest);
|
||||||
|
|||||||
@@ -1069,6 +1069,42 @@ try {
|
|||||||
console.error('Failed to migrate public_donation_orders:', e);
|
console.error('Failed to migrate public_donation_orders:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safe migration: QR Custom Payment (nominal bebas di luar billing)
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_qr_payments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
category TEXT DEFAULT '',
|
||||||
|
amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unique_code INTEGER,
|
||||||
|
amount_unique INTEGER,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
share_token TEXT NOT NULL,
|
||||||
|
note TEXT DEFAULT '',
|
||||||
|
created_by TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (NOW_LOCAL()),
|
||||||
|
expires_at TEXT,
|
||||||
|
paid_at DATETIME,
|
||||||
|
paid_by TEXT DEFAULT '',
|
||||||
|
notif_id INTEGER,
|
||||||
|
warning_at DATETIME,
|
||||||
|
resolved_by TEXT DEFAULT '',
|
||||||
|
resolved_at DATETIME,
|
||||||
|
resolution_note TEXT DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_qr_token ON custom_qr_payments(share_token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_qr_status ON custom_qr_payments(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_qr_amount ON custom_qr_payments(amount_unique, status);
|
||||||
|
`);
|
||||||
|
const cols = db.prepare("PRAGMA table_info(webhook_payment_notifs)").all();
|
||||||
|
if (cols.length > 0 && !cols.some(c => c.name === 'matched_custom_qr_id')) {
|
||||||
|
db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_custom_qr_id INTEGER");
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to migrate custom_qr_payments:', e);
|
||||||
|
}
|
||||||
|
|
||||||
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
|
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
|
||||||
try {
|
try {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -278,6 +278,26 @@ function formatTimeLocal(date) {
|
|||||||
* Memastikan semua setting wajib ada di settings.json dengan default value.
|
* Memastikan semua setting wajib ada di settings.json dengan default value.
|
||||||
* Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru.
|
* Dijalankan saat startup aplikasi untuk migrasi/inisialisasi setting baru.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Pastikan gowa_webhook_secret tidak memakai default 'secret'.
|
||||||
|
* Dibuat otomatis (32 byte hex) bila kosong/default/terlalu pendek.
|
||||||
|
*/
|
||||||
|
function ensureWebhookSecret(force) {
|
||||||
|
const current = String(getSetting('webhook_secret', '') || '').trim();
|
||||||
|
if (!force && current && current !== 'billing-rtrw-secret-key' && current.length >= 24) return current;
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
saveSettings({ webhook_secret: secret });
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGowaWebhookSecret(force) {
|
||||||
|
const current = String(getSetting('gowa_webhook_secret', '') || '').trim();
|
||||||
|
if (!force && current && current !== 'secret' && current.length >= 24) return current;
|
||||||
|
const secret = crypto.randomBytes(32).toString('hex');
|
||||||
|
saveSettings({ gowa_webhook_secret: secret });
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureDefaultSettings() {
|
function ensureDefaultSettings() {
|
||||||
try {
|
try {
|
||||||
const currentSettings = getSettings();
|
const currentSettings = getSettings();
|
||||||
@@ -293,6 +313,9 @@ function ensureDefaultSettings() {
|
|||||||
custom_ai_base_url: '',
|
custom_ai_base_url: '',
|
||||||
custom_ai_model: 'llama3',
|
custom_ai_model: 'llama3',
|
||||||
ai_self_healing_enabled: false,
|
ai_self_healing_enabled: false,
|
||||||
|
company_subheader: '',
|
||||||
|
update_base_url: 'https://gitea.oxpoe.xyz',
|
||||||
|
update_repo: 'oxpoe/open-isp',
|
||||||
// RADIUS Server settings (ditambahkan untuk update dari Gitea)
|
// RADIUS Server settings (ditambahkan untuk update dari Gitea)
|
||||||
radius_enabled: '0',
|
radius_enabled: '0',
|
||||||
radius_secret: 'secret123',
|
radius_secret: 'secret123',
|
||||||
@@ -358,5 +381,7 @@ module.exports = {
|
|||||||
getNowLocalISO,
|
getNowLocalISO,
|
||||||
parseDateInTimezone,
|
parseDateInTimezone,
|
||||||
startSettingsWatcher,
|
startSettingsWatcher,
|
||||||
ensureDefaultSettings
|
ensureDefaultSettings,
|
||||||
|
ensureGowaWebhookSecret,
|
||||||
|
ensureWebhookSecret
|
||||||
};
|
};
|
||||||
|
|||||||
54
docker-compose.gowa.yml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# GOWA — WhatsApp gateway + dashboard (UI) untuk OPEN-ISP
|
||||||
|
#
|
||||||
|
# Kompatibel dengan docker compose maupun podman compose.
|
||||||
|
#
|
||||||
|
# 1. Siapkan kredensial:
|
||||||
|
# cp .env.gowa.example .env.gowa
|
||||||
|
# # isi GOWA_BASIC_AUTH dan GOWA_WEBHOOK_SECRET (openssl rand -hex 32)
|
||||||
|
#
|
||||||
|
# 2. Jalankan:
|
||||||
|
# podman compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
|
||||||
|
# # atau: docker compose --env-file .env.gowa -f docker-compose.gowa.yml up -d
|
||||||
|
#
|
||||||
|
# 3. Buka dashboard: http://<ip-host>:3000 (login Basic Auth di atas)
|
||||||
|
# Scan QR di sana, atau dari OPEN-ISP → WhatsApp.
|
||||||
|
#
|
||||||
|
# 4. Di OPEN-ISP (Admin → WhatsApp → GOWA):
|
||||||
|
# base URL : http://127.0.0.1:3000 (app di host yang sama)
|
||||||
|
# username/pass : sesuai GOWA_BASIC_AUTH
|
||||||
|
# webhook secret: sama dengan GOWA_WEBHOOK_SECRET
|
||||||
|
# gateway : GOWA
|
||||||
|
#
|
||||||
|
# Catatan:
|
||||||
|
# - UI (gowa-ui) diunduh otomatis oleh GOWA saat start; butuh internet.
|
||||||
|
# Server tanpa internet: set APP_UI_AUTO_UPDATE=false lalu taruh
|
||||||
|
# gowa-ui.html di volume gowa_storages (folder /app/storages/ui).
|
||||||
|
# - host.containers.internal = IP host dari dalam container (Podman/Docker).
|
||||||
|
# Bila tidak resolve, ganti GOWA_WEBHOOK_URL dengan IP LAN host,
|
||||||
|
# mis. http://10.44.33.51:3001/api/webhook/gowa
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
services:
|
||||||
|
gowa:
|
||||||
|
image: docker.io/aldinokemal2104/go-whatsapp-web-multidevice:v9.8.0
|
||||||
|
container_name: gowa
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${GOWA_BIND:-0.0.0.0}:${GOWA_PORT:-3000}:3000"
|
||||||
|
volumes:
|
||||||
|
- gowa_storages:/app/storages
|
||||||
|
- gowa_statics:/app/statics
|
||||||
|
environment:
|
||||||
|
APP_PORT: "3000"
|
||||||
|
APP_OS: "OPEN-ISP"
|
||||||
|
APP_BASIC_AUTH: "${GOWA_BASIC_AUTH:?isi GOWA_BASIC_AUTH=user:password}"
|
||||||
|
APP_UI_ENABLED: "${GOWA_UI_ENABLED:-true}"
|
||||||
|
APP_UI_AUTO_UPDATE: "${GOWA_UI_AUTO_UPDATE:-true}"
|
||||||
|
WHATSAPP_ACCOUNT_VALIDATION: "false"
|
||||||
|
WHATSAPP_WEBHOOK: "${GOWA_WEBHOOK_URL:?isi GOWA_WEBHOOK_URL}"
|
||||||
|
WHATSAPP_WEBHOOK_SECRET: "${GOWA_WEBHOOK_SECRET:?isi GOWA_WEBHOOK_SECRET}"
|
||||||
|
WHATSAPP_WEBHOOK_EVENTS: "message"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gowa_storages:
|
||||||
|
gowa_statics:
|
||||||
@@ -47,7 +47,7 @@ Rilis ini menggabungkan pembaruan fitur dari repo upstream (`alijayanet/billing-
|
|||||||
- Kolom API key AI disamarkan di halaman Pengaturan.
|
- Kolom API key AI disamarkan di halaman Pengaturan.
|
||||||
|
|
||||||
## Dokumentasi
|
## Dokumentasi
|
||||||
- 10 screenshot (desktop + mobile) di `docs/screenshots/`.
|
- 10 screenshot (desktop + mobile) di `docs/screenshots/` (format WebP, ringan).
|
||||||
|
|
||||||
## Cara upgrade
|
## Cara upgrade
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 414 KiB |
BIN
docs/screenshots/01-landing.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 225 KiB |
BIN
docs/screenshots/02-admin-login.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/03-admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 216 KiB |
BIN
docs/screenshots/04-admin-customers.webp
Normal file
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 189 KiB |
BIN
docs/screenshots/05-admin-inventory.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 119 KiB |
BIN
docs/screenshots/06-inventory-stok-keluar.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 158 KiB |
BIN
docs/screenshots/07-admin-settings-ai.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 321 KiB |
BIN
docs/screenshots/08-customer-login.webp
Normal file
|
After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 181 KiB |
BIN
docs/screenshots/09-customer-dashboard.webp
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 318 KiB |
BIN
docs/screenshots/10-mobile-customer-login.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -1,20 +1,22 @@
|
|||||||
# Screenshot Aplikasi (v0.5.2)
|
# Screenshot Aplikasi (v0.5.2)
|
||||||
|
|
||||||
|
Format **WebP** (kualitas 78, lebar maks 1280px) — total ±0,36 MB (turun ~84% dari PNG).
|
||||||
|
|
||||||
Diambil dari instance uji lokal (`http://localhost:3001`) dengan data demo
|
Diambil dari instance uji lokal (`http://localhost:3001`) dengan data demo
|
||||||
(`node scripts/seed-demo.js`), viewport 1440×900 (mobile 390×844).
|
(`node scripts/seed-demo.js`), viewport 1440×900 (mobile 390×844).
|
||||||
|
|
||||||
| File | Halaman |
|
| File | Halaman |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `01-landing.png` | Landing page publik |
|
| `01-landing.webp` | Landing page publik |
|
||||||
| `02-admin-login.png` | Halaman login admin |
|
| `02-admin-login.webp` | Halaman login admin |
|
||||||
| `03-admin-dashboard.png` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
|
| `03-admin-dashboard.webp` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
|
||||||
| `04-admin-customers.png` | Daftar pelanggan |
|
| `04-admin-customers.webp` | Daftar pelanggan |
|
||||||
| `05-admin-inventory.png` | Inventaris stok |
|
| `05-admin-inventory.webp` | Inventaris stok |
|
||||||
| `06-inventory-stok-keluar.png` | Modal Stok Keluar (FIFO / serial number) |
|
| `06-inventory-stok-keluar.webp` | Modal Stok Keluar (FIFO / serial number) |
|
||||||
| `07-admin-settings-ai.png` | Pengaturan → AI & Self-Healing (key disamarkan) |
|
| `07-admin-settings-ai.webp` | Pengaturan → AI & Self-Healing (key disamarkan) |
|
||||||
| `08-customer-login.png` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
|
| `08-customer-login.webp` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
|
||||||
| `09-customer-dashboard.png` | Dashboard pelanggan |
|
| `09-customer-dashboard.webp` | Dashboard pelanggan |
|
||||||
| `10-mobile-customer-login.png` | Login pelanggan (tampilan mobile) |
|
| `10-mobile-customer-login.webp` | Login pelanggan (tampilan mobile) |
|
||||||
|
|
||||||
Catatan: field API key di halaman Pengaturan memakai `type="password"`
|
Catatan: field API key di halaman Pengaturan memakai `type="password"`
|
||||||
sehingga tidak tampil di screenshot maupun saat layar dibagikan.
|
sehingga tidak tampil di screenshot maupun saat layar dibagikan.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/admin-billing.webp
Normal file
|
After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 87 KiB |
BIN
docs/screenshots/admin-customers.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/admin-dashboard.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB |
BIN
docs/screenshots/admin-login.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 55 KiB |
BIN
docs/screenshots/admin-olts.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 37 KiB |
BIN
docs/screenshots/customer-billing.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/customer-dashboard.webp
Normal file
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/customer-login.webp
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 83 KiB |
BIN
docs/screenshots/landing.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -10,18 +10,19 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
const PORT = parseInt(process.env.ISOLIR_REDIRECT_PORT || '8099', 10);
|
const PORT = parseInt(process.env.ISOLIR_REDIRECT_PORT || '8099', 10);
|
||||||
let TARGET = process.env.ISOLIR_REDIRECT_URL || '';
|
|
||||||
|
|
||||||
// Coba ambil target dari settings.json bila tersedia
|
// Target dibaca ulang dari settings (bisa diubah dari dashboard tanpa restart).
|
||||||
try {
|
function currentTarget() {
|
||||||
|
try {
|
||||||
const { getSetting } = require('./config/settingsManager');
|
const { getSetting } = require('./config/settingsManager');
|
||||||
const s = getSetting('isolir_portal_url', '') || '';
|
const s = String(getSetting('isolir_portal_url', '') || '').trim();
|
||||||
if (s) TARGET = s;
|
if (s) return s;
|
||||||
} catch (_) { /* abaikan */ }
|
} catch (_) {}
|
||||||
|
return String(process.env.ISOLIR_REDIRECT_URL || '').trim() || 'http://localhost:3001/isolated';
|
||||||
if (!TARGET) TARGET = 'http://localhost:3001/isolated';
|
}
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
const TARGET = currentTarget();
|
||||||
// Jangan cache supaya pelanggan selalu diarahkan ke portal terbaru
|
// Jangan cache supaya pelanggan selalu diarahkan ke portal terbaru
|
||||||
res.writeHead(302, {
|
res.writeHead(302, {
|
||||||
Location: TARGET,
|
Location: TARGET,
|
||||||
@@ -40,7 +41,7 @@ const server = http.createServer((req, res) => {
|
|||||||
server.on('error', (e) => console.error('[isolir-redirect] listen error:', e.message));
|
server.on('error', (e) => console.error('[isolir-redirect] listen error:', e.message));
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`[isolir-redirect] listening on 0.0.0.0:${PORT} -> ${TARGET}`);
|
console.log(`[isolir-redirect] listening on 0.0.0.0:${PORT} -> ${currentTarget()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('SIGTERM', () => { server.close(() => process.exit(0)); });
|
process.on('SIGTERM', () => { server.close(() => process.exit(0)); });
|
||||||
|
|||||||
@@ -1178,7 +1178,7 @@
|
|||||||
"deactivate_months": "Arrears Limit (months)",
|
"deactivate_months": "Arrears Limit (months)",
|
||||||
"deactivate_months_hint": "Customer is deactivated (isolated) after this many unpaid months (3 = 3 unpaid new invoices).",
|
"deactivate_months_hint": "Customer is deactivated (isolated) after this many unpaid months (3 = 3 unpaid new invoices).",
|
||||||
"auto_delete": "Auto-Delete Customer (over limit)",
|
"auto_delete": "Auto-Delete Customer (over limit)",
|
||||||
"auto_delete_hint": "If Active: customers overdue beyond the limit (e.g. 4th month) are deleted automatically — must re-register.",
|
"auto_delete_hint": "If Active: customers overdue one month past the limit are deleted automatically — must re-register.",
|
||||||
"arrears_notice": "Auto Arrears Warning",
|
"arrears_notice": "Auto Arrears Warning",
|
||||||
"arrears_notice_hint": "Send a WhatsApp warning when arrears approach the limit. Template in the Templates & Preview tab.",
|
"arrears_notice_hint": "Send a WhatsApp warning when arrears approach the limit. Template in the Templates & Preview tab.",
|
||||||
"manual_action": "Manual Action",
|
"manual_action": "Manual Action",
|
||||||
@@ -1283,20 +1283,20 @@
|
|||||||
"st_visible": "Visible",
|
"st_visible": "Visible",
|
||||||
"st_hidden": "Hide",
|
"st_hidden": "Hide",
|
||||||
"st_locked": "Lock",
|
"st_locked": "Lock",
|
||||||
"pass_label": "Activation Code (Free)",
|
"pass_label": "Menu activation",
|
||||||
"pass_ph": "Enter the activation password before saving",
|
"pass_ph": "Not required",
|
||||||
"pass_hint": "Sidebar menu status changes are saved only if the activation password is correct.",
|
"pass_hint": "Not required.",
|
||||||
"no_pass": "No activation code yet?",
|
"no_pass": "Menus can be shown/hidden from this page.",
|
||||||
"get_donation": "Get a FREE activation code on the OPEN-ISP Support page",
|
"get_donation": "Support OPEN-ISP development",
|
||||||
"or_contact": "or contact",
|
"or_contact": "",
|
||||||
"donation_page": "Free Activation Code (OPEN-ISP Support)",
|
"donation_page": "Free Activation Code (OPEN-ISP Support)",
|
||||||
"save": "Save Sidebar Settings",
|
"save": "Save Sidebar Settings",
|
||||||
"only_admin": "Only the main admin can change sidebar menu status.",
|
"only_admin": "Only the main admin can change sidebar menu status.",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"note_lock": "If a Locked menu is clicked from the sidebar, the system shows how to get a FREE activation code or contact",
|
"note_lock": "A locked menu stays closed until enabled from this page.",
|
||||||
"note1": "Hidden status removes the menu from the sidebar and denies direct URL access.",
|
"note1": "Hidden status removes the menu from the sidebar and denies direct URL access.",
|
||||||
"note2": "Locked status keeps the menu visible but cannot be opened until changed on this page.",
|
"note2": "Locked status keeps the menu visible but cannot be opened until changed on this page.",
|
||||||
"note3": "Menu status changes can only be saved after entering the correct activation password."
|
"note3": ""
|
||||||
},
|
},
|
||||||
"acsdev": {
|
"acsdev": {
|
||||||
"title": "Device Detail",
|
"title": "Device Detail",
|
||||||
|
|||||||
@@ -1178,7 +1178,7 @@
|
|||||||
"deactivate_months": "Batas Tunggakan (bulan)",
|
"deactivate_months": "Batas Tunggakan (bulan)",
|
||||||
"deactivate_months_hint": "Pelanggan dinonaktif (isolir) bila menunggak sebanyak ini (3 = 3x tagihan baru belum lunas).",
|
"deactivate_months_hint": "Pelanggan dinonaktif (isolir) bila menunggak sebanyak ini (3 = 3x tagihan baru belum lunas).",
|
||||||
"auto_delete": "Auto-Hapus Pelanggan (lewat batas)",
|
"auto_delete": "Auto-Hapus Pelanggan (lewat batas)",
|
||||||
"auto_delete_hint": "Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.",
|
"auto_delete_hint": "Jika Aktif: pelanggan menunggak satu bulan setelah batas dihapus otomatis — harus daftar ulang.",
|
||||||
"arrears_notice": "Auto-Peringatan Tunggakan",
|
"arrears_notice": "Auto-Peringatan Tunggakan",
|
||||||
"arrears_notice_hint": "Kirim WA peringatan saat menunggak mendekati batas. Template di tab Template & Preview.",
|
"arrears_notice_hint": "Kirim WA peringatan saat menunggak mendekati batas. Template di tab Template & Preview.",
|
||||||
"manual_action": "Aksi Manual",
|
"manual_action": "Aksi Manual",
|
||||||
@@ -1283,20 +1283,20 @@
|
|||||||
"st_visible": "Tampil",
|
"st_visible": "Tampil",
|
||||||
"st_hidden": "Sembunyikan",
|
"st_hidden": "Sembunyikan",
|
||||||
"st_locked": "Kunci",
|
"st_locked": "Kunci",
|
||||||
"pass_label": "Kode Aktivasi (Gratis)",
|
"pass_label": "Aktivasi menu",
|
||||||
"pass_ph": "Masukkan password aktivasi sebelum menyimpan",
|
"pass_ph": "Tidak diperlukan",
|
||||||
"pass_hint": "Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.",
|
"pass_hint": "Tidak diperlukan.",
|
||||||
"no_pass": "Belum punya kode aktivasi?",
|
"no_pass": "Menu bisa ditampilkan/disembunyikan dari halaman ini.",
|
||||||
"get_donation": "Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP",
|
"get_donation": "Dukung pengembangan OPEN-ISP",
|
||||||
"or_contact": "atau hubungi",
|
"or_contact": "",
|
||||||
"donation_page": "Kode Aktivasi Gratis (Dukungan OPEN-ISP)",
|
"donation_page": "Kode Aktivasi Gratis (Dukungan OPEN-ISP)",
|
||||||
"save": "Simpan Pengaturan Sidebar",
|
"save": "Simpan Pengaturan Sidebar",
|
||||||
"only_admin": "Hanya admin utama yang dapat mengubah status menu sidebar.",
|
"only_admin": "Hanya admin utama yang dapat mengubah status menu sidebar.",
|
||||||
"notes": "Catatan",
|
"notes": "Catatan",
|
||||||
"note_lock": "Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi",
|
"note_lock": "Menu berstatus Kunci tidak dapat dibuka sampai diaktifkan.",
|
||||||
"note1": "Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.",
|
"note1": "Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.",
|
||||||
"note2": "Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.",
|
"note2": "Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.",
|
||||||
"note3": "Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar."
|
"note3": ""
|
||||||
},
|
},
|
||||||
"acsdev": {
|
"acsdev": {
|
||||||
"title": "Detail Perangkat",
|
"title": "Detail Perangkat",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-isp",
|
"name": "open-isp",
|
||||||
"version": "0.5.3",
|
"version": "0.6.17",
|
||||||
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
|
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
|
||||||
"main": "app-customer.js",
|
"main": "app-customer.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -50,12 +50,12 @@
|
|||||||
},
|
},
|
||||||
"author": "oxpoe",
|
"author": "oxpoe",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"homepage": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp#readme",
|
"homepage": "https://gitea.oxpoe.xyz/oxpoe/open-isp#readme",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://gitea.oxpoe.qzz.io/oxpoe/open-isp.git"
|
"url": "git+https://gitea.oxpoe.xyz/oxpoe/open-isp.git"
|
||||||
},
|
},
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://gitea.oxpoe.qzz.io/oxpoe/open-isp/issues"
|
"url": "https://gitea.oxpoe.xyz/oxpoe/open-isp/issues"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -492,6 +492,11 @@ function getDeviceParameterValue(device, keys, filterFn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractPppoeIp(d) {
|
function extractPppoeIp(d) {
|
||||||
|
// Virtual parameters GenieACS (lebih cepat & akurat bila tersedia)
|
||||||
|
for (const k of ['VirtualParameters.pppoeIP', 'VirtualParameters.pppIP', 'VirtualParameters.WanIP', 'VirtualParameters.IPTR069', 'VirtualParameters.pppoe_ip']) {
|
||||||
|
const val = getNestedValue(d, k);
|
||||||
|
if (val && typeof val === 'string' && val.trim() && val !== '-' && val !== '0.0.0.0' && val !== 'N/A') return val.trim();
|
||||||
|
}
|
||||||
const ip = getDeviceParameterValue(d, PPPOE_IP_KEYS, (matchedPath, value, device) => {
|
const ip = getDeviceParameterValue(d, PPPOE_IP_KEYS, (matchedPath, value, device) => {
|
||||||
if (!value || value === '0.0.0.0' || value === '-') return false;
|
if (!value || value === '0.0.0.0' || value === '-') return false;
|
||||||
|
|
||||||
@@ -511,6 +516,10 @@ function extractPppoeIp(d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractPppoeUser(d) {
|
function extractPppoeUser(d) {
|
||||||
|
for (const k of ['VirtualParameters.pppoeUsername2', 'VirtualParameters.pppoeUsername', 'VirtualParameters.pppUsername', 'VirtualParameters.PPPoEUser', 'VirtualParameters.pppoe_username', 'VirtualParameters.username']) {
|
||||||
|
const val = getNestedValue(d, k);
|
||||||
|
if (val && typeof val === 'string' && val.trim() && val !== '-' && val !== 'N/A') return val.trim();
|
||||||
|
}
|
||||||
const user = getDeviceParameterValue(d, PPPOE_USER_KEYS, (matchedPath, value, device) => {
|
const user = getDeviceParameterValue(d, PPPOE_USER_KEYS, (matchedPath, value, device) => {
|
||||||
if (!value || value === '-') return false;
|
if (!value || value === '-') return false;
|
||||||
// Abaikan nilai generik yang bukan user PPPoE pelanggan
|
// Abaikan nilai generik yang bukan user PPPoE pelanggan
|
||||||
@@ -705,7 +714,7 @@ function requireSidebarMenuAccess(menuKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (access.reason === 'locked') {
|
if (access.reason === 'locked') {
|
||||||
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password.` };
|
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` };
|
||||||
return res.redirect('/admin/sidebar-settings');
|
return res.redirect('/admin/sidebar-settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1601,6 +1610,36 @@ router.get('/api/wifi-settings/:deviceId', requireAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/api/device/:deviceId/ssid', requireAdmin, express.json(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deviceId = req.params.deviceId;
|
||||||
|
const ssid = String(req.body.ssid || '').trim();
|
||||||
|
if (!ssid || ssid.length < 2) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Nama WiFi (SSID) minimal 2 karakter' });
|
||||||
|
}
|
||||||
|
const ok = await customerDevice.updateSSID(deviceId, ssid);
|
||||||
|
if (!ok) return res.status(500).json({ success: false, message: 'Gagal mengubah SSID. Pastikan perangkat aktif.' });
|
||||||
|
return res.json({ success: true, message: `SSID diubah menjadi "${ssid}"` });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/device/:deviceId/password', requireAdmin, express.json(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deviceId = req.params.deviceId;
|
||||||
|
const password = String(req.body.password || '').trim();
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Sandi WiFi minimal 8 karakter' });
|
||||||
|
}
|
||||||
|
const ok = await customerDevice.updatePassword(deviceId, password);
|
||||||
|
if (!ok) return res.status(500).json({ success: false, message: 'Gagal mengubah sandi WiFi. Pastikan perangkat aktif.' });
|
||||||
|
return res.json({ success: true, message: 'Sandi WiFi berhasil diperbarui' });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/api/add-wan-status/:deviceId', requireAdmin, async (req, res) => {
|
router.get('/api/add-wan-status/:deviceId', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { deviceId } = req.params;
|
const { deviceId } = req.params;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const _jimpMod = require('jimp');
|
|||||||
const Jimp = _jimpMod.Jimp || _jimpMod;
|
const Jimp = _jimpMod.Jimp || _jimpMod;
|
||||||
const jsQR = require('jsqr');
|
const jsQR = require('jsqr');
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('../services/invoiceQrisService');
|
||||||
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
|
const { MultiFormatReader, BarcodeFormat, DecodeHintType, BinaryBitmap, HybridBinarizer, RGBLuminanceSource } = require('@zxing/library');
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
const acsPortal = require('./acsPortal');
|
const acsPortal = require('./acsPortal');
|
||||||
@@ -317,7 +318,7 @@ function requireSidebarMenuAccess(menuKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (access.reason === 'locked') {
|
if (access.reason === 'locked') {
|
||||||
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Hubungi ${sidebarMenuSvc.FEATURE_CONTACT_PHONE} untuk mendapatkan password.` };
|
req.session._msg = { type: 'error', text: `Menu "${access.menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` };
|
||||||
return res.redirect('/admin/sidebar-settings');
|
return res.redirect('/admin/sidebar-settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,9 +341,9 @@ function readTextFileSafe(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runCmd(cmd, args, cwd) {
|
function runCmd(cmd, args, cwd, timeoutMs = 0) {
|
||||||
try {
|
try {
|
||||||
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
|
const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
|
||||||
return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
return { ok: r.status === 0, code: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, code: -1, stdout: '', stderr: String(e?.message || e) };
|
return { ok: false, code: -1, stdout: '', stderr: String(e?.message || e) };
|
||||||
@@ -396,9 +397,11 @@ function getUpdateInfo(repoRoot) {
|
|||||||
info.branch = branch;
|
info.branch = branch;
|
||||||
|
|
||||||
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
|
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
|
||||||
|
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
|
||||||
info.repo = _repo || 'origin';
|
info.repo = _repo || 'origin';
|
||||||
|
info.baseUrl = _base;
|
||||||
const fetch = _repo
|
const fetch = _repo
|
||||||
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
|
? runCmd('git', ['fetch', '--prune', _base + '/' + _repo + '.git', branch], repoRoot, 120000)
|
||||||
: runCmd('git', ['fetch', '--prune'], repoRoot);
|
: runCmd('git', ['fetch', '--prune'], repoRoot);
|
||||||
if (!fetch.ok) {
|
if (!fetch.ok) {
|
||||||
info.error = 'Gagal git fetch: ' + (fetch.stderr || fetch.stdout || '').trim();
|
info.error = 'Gagal git fetch: ' + (fetch.stderr || fetch.stdout || '').trim();
|
||||||
@@ -3103,7 +3106,6 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
|||||||
const baseAmount = Number(inv.amount || 0);
|
const baseAmount = Number(inv.amount || 0);
|
||||||
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
|
if (!Number.isFinite(baseAmount) || baseAmount <= 0) throw new Error('Nominal tagihan tidak valid');
|
||||||
|
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
const update = db.prepare(`
|
const update = db.prepare(`
|
||||||
UPDATE invoices
|
UPDATE invoices
|
||||||
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
||||||
@@ -3116,7 +3118,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
|||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const code = 1 + Math.floor(Math.random() * 999);
|
const code = 1 + Math.floor(Math.random() * 999);
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3126,7 +3128,7 @@ router.post('/billing/:id/qris-assign', requireAdminSession, (req, res) => {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 1; code <= 999; code++) {
|
for (let code = 1; code <= 999; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3200,7 +3202,6 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
|
if (hasStaticQris && String(inv.status) === 'unpaid' && (!qrisAmountUnique || !qrisCode || (qrisAmountUnique - qrisCode !== baseAmount))) {
|
||||||
const invId = Number(inv.id);
|
const invId = Number(inv.id);
|
||||||
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
|
if (Number.isFinite(invId) && invId > 0 && Number.isFinite(baseAmount) && baseAmount > 0) {
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
const update = db.prepare(`
|
const update = db.prepare(`
|
||||||
UPDATE invoices
|
UPDATE invoices
|
||||||
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
SET qris_unique_code=?, qris_amount_unique=?, qris_assigned_at=(NOW_LOCAL())
|
||||||
@@ -3212,7 +3213,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
|
|
||||||
if (chosenCode > 0) {
|
if (chosenCode > 0) {
|
||||||
const pAmt = baseAmount + chosenCode;
|
const pAmt = baseAmount + chosenCode;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
chosenAmount = pAmt;
|
chosenAmount = pAmt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3221,7 +3222,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const code = 1 + Math.floor(Math.random() * 999);
|
const code = 1 + Math.floor(Math.random() * 999);
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3231,7 +3232,7 @@ router.post('/billing/:id/whatsapp', requireAdminSession, async (req, res) => {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 1; code <= 999; code++) {
|
for (let code = 1; code <= 999; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3987,11 +3988,6 @@ router.get('/sidebar-settings', requireAdminSession, (req, res) => {
|
|||||||
|
|
||||||
router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
|
router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.urlencoded({ extended: true }), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const featurePassword = String(req.body.feature_password || '').trim();
|
|
||||||
if (!sidebarMenuSvc.isFeaturePasswordValid(featurePassword)) {
|
|
||||||
throw new Error(`Password aktivasi salah. Hubungi ${sidebarMenuSvc.getFeatureContactPhone()} untuk mendapatkan password yang benar.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuStates = sidebarMenuSvc.sanitizeMenuStates(req.body.menu_state || {});
|
const menuStates = sidebarMenuSvc.sanitizeMenuStates(req.body.menu_state || {});
|
||||||
const success = sidebarMenuSvc.saveMenuStates(menuStates);
|
const success = sidebarMenuSvc.saveMenuStates(menuStates);
|
||||||
if (!success) throw new Error('Gagal menyimpan pengaturan sidebar');
|
if (!success) throw new Error('Gagal menyimpan pengaturan sidebar');
|
||||||
@@ -4017,14 +4013,133 @@ router.post('/sidebar-settings', requireAdminSession, restrictToAdmin, express.u
|
|||||||
res.redirect('/admin/sidebar-settings');
|
res.redirect('/admin/sidebar-settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
|
// ─── QR CUSTOM PAYMENT (nominal bebas, kode unik 20 menit) ─────────────────
|
||||||
res.render('admin/service_notice', {
|
router.get('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), (req, res) => {
|
||||||
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
|
const svc = require('../services/customQrService');
|
||||||
settings: getSettings(), msg: flashMsg(req)
|
let detail = null;
|
||||||
|
const detailId = Number(req.query.id) || 0;
|
||||||
|
if (detailId > 0) { try { detail = svc.getById(detailId); } catch (e) {} }
|
||||||
|
const filterFrom = String(req.query.from || '').trim();
|
||||||
|
const filterTo = String(req.query.to || '').trim();
|
||||||
|
const settings = getSettings();
|
||||||
|
const baseUrl = String(settings.public_base_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
|
||||||
|
res.render('admin/custom_qr', {
|
||||||
|
title: 'QR-Custom Payment',
|
||||||
|
company: company(),
|
||||||
|
activePage: 'custom_qr',
|
||||||
|
msg: flashMsg(req),
|
||||||
|
rows: svc.list(200, { from: filterFrom, to: filterTo }),
|
||||||
|
filterFrom, filterTo,
|
||||||
|
stats: svc.stats(),
|
||||||
|
detail,
|
||||||
|
baseUrl,
|
||||||
|
durationMinutes: svc.DURATION_MINUTES,
|
||||||
|
hasQrisPayload: !!String(settings.qris_static_payload || '').trim(),
|
||||||
|
ready: svc.isReady()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), (req, res) => {
|
router.post('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
const svc = require('../services/customQrService');
|
||||||
|
const row = svc.create({
|
||||||
|
title: req.body.title,
|
||||||
|
category: req.body.category,
|
||||||
|
amount: req.body.amount,
|
||||||
|
note: req.body.note,
|
||||||
|
createdBy: resolvePaidByName(req, 'Admin')
|
||||||
|
});
|
||||||
|
req.session._msg = { type: 'success', text: `QR custom dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} — berlaku ${svc.DURATION_MINUTES} menit.` };
|
||||||
|
return res.redirect('/admin/custom-qr?id=' + row.id);
|
||||||
|
} catch (e) {
|
||||||
|
req.session._msg = { type: 'error', text: 'Gagal membuat QR custom: ' + e.message };
|
||||||
|
return res.redirect('/admin/custom-qr');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/custom-qr/:id/manual-paid', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
require('../services/customQrService').markManualPaid(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||||
|
req.session._msg = { type: 'success', text: 'Pembayaran custom ditandai LUNAS (manual).' };
|
||||||
|
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||||
|
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
function _checkAdminPassword(req) {
|
||||||
|
const pass = String((req.body && req.body.password) || '');
|
||||||
|
const expected = String(getSetting('admin_password', 'demo-isp123') || 'demo-isp123');
|
||||||
|
return !!expected && pass === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/custom-qr/:id/delete', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
|
||||||
|
const n = require('../services/customQrService').remove(req.params.id);
|
||||||
|
req.session._msg = n ? { type: 'success', text: 'QR custom dihapus.' } : { type: 'error', text: 'Data tidak ditemukan.' };
|
||||||
|
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||||
|
res.redirect('/admin/custom-qr');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/custom-qr/reset', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
|
||||||
|
const n = require('../services/customQrService').clearAll();
|
||||||
|
req.session._msg = { type: 'success', text: `Histori QR custom direset (${n} data dihapus).` };
|
||||||
|
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||||
|
res.redirect('/admin/custom-qr');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/custom-qr/delete-bulk', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
|
||||||
|
const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids];
|
||||||
|
const n = require('../services/customQrService').removeMany(ids);
|
||||||
|
req.session._msg = { type: 'success', text: `${n} QR custom dihapus.` };
|
||||||
|
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||||
|
const q = [];
|
||||||
|
if (req.body.from) q.push('from=' + encodeURIComponent(req.body.from));
|
||||||
|
if (req.body.to) q.push('to=' + encodeURIComponent(req.body.to));
|
||||||
|
res.redirect('/admin/custom-qr' + (q.length ? ('?' + q.join('&')) : ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/custom-qr/:id/regenerate', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
const row = require('../services/customQrService').regenerate(req.params.id, resolvePaidByName(req, 'Admin'));
|
||||||
|
req.session._msg = { type: 'success', text: `QR baru dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} (kode ${row.unique_code}).` };
|
||||||
|
return res.redirect('/admin/custom-qr?id=' + row.id);
|
||||||
|
} catch (e) {
|
||||||
|
req.session._msg = { type: 'error', text: e.message };
|
||||||
|
return res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||||
|
req.session._msg = { type: 'success', text: 'Pembayaran setelah 20 menit diterima.' };
|
||||||
|
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||||
|
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/custom-qr/:id/reject', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||||
|
try {
|
||||||
|
require('../services/customQrService').reject(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||||
|
req.session._msg = { type: 'success', text: 'Pembayaran ditolak / refund dicatat.' };
|
||||||
|
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
|
||||||
|
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), (req, res) => {
|
||||||
|
let routers = [];
|
||||||
|
try { routers = db.prepare('SELECT id, name FROM routers WHERE is_active = 1 ORDER BY name ASC').all(); } catch (_) {}
|
||||||
|
res.render('admin/service_notice', {
|
||||||
|
title: 'Status Layanan & Pengumuman', company: company(), activePage: 'service_notice',
|
||||||
|
settings: getSettings(), msg: flashMsg(req),
|
||||||
|
areas: customerSvc.getAllCustomerAreas(), routers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('service_notice'), express.urlencoded({ extended: true }), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const allowed = ['NORMAL', 'GANGGUAN_MASAL', 'PERBAIKAN'];
|
const allowed = ['NORMAL', 'GANGGUAN_MASAL', 'PERBAIKAN'];
|
||||||
let mode = String(req.body.notice_mode || 'NORMAL').trim().toUpperCase();
|
let mode = String(req.body.notice_mode || 'NORMAL').trim().toUpperCase();
|
||||||
@@ -4032,6 +4147,7 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se
|
|||||||
let interval = parseInt(req.body.notice_interval_hours, 10);
|
let interval = parseInt(req.body.notice_interval_hours, 10);
|
||||||
if (!interval || interval < 1) interval = 5;
|
if (!interval || interval < 1) interval = 5;
|
||||||
if (interval > 168) interval = 168;
|
if (interval > 168) interval = 168;
|
||||||
|
const prevMode = String(getSetting('notice_mode', 'NORMAL') || 'NORMAL').toUpperCase();
|
||||||
saveSettings({
|
saveSettings({
|
||||||
notice_mode: mode,
|
notice_mode: mode,
|
||||||
notice_start: String(req.body.notice_start || '').trim(),
|
notice_start: String(req.body.notice_start || '').trim(),
|
||||||
@@ -4041,7 +4157,27 @@ router.post('/service-notice', requireAdminSession, requireSidebarMenuAccess('se
|
|||||||
const noticeSvc = require('../services/serviceNoticeService');
|
const noticeSvc = require('../services/serviceNoticeService');
|
||||||
const n = noticeSvc.computeNotice();
|
const n = noticeSvc.computeNotice();
|
||||||
const detail = n.active ? (n.label + (n.estimateText ? ' — estimasi ' + n.estimateText : '')) : 'Normal (tanpa pengumuman)';
|
const detail = n.active ? (n.label + (n.estimateText ? ' — estimasi ' + n.estimateText : '')) : 'Normal (tanpa pengumuman)';
|
||||||
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail };
|
let waInfo = '';
|
||||||
|
if (req.body.notice_wa === '1') {
|
||||||
|
if (mode === prevMode) {
|
||||||
|
waInfo = ' Notifikasi WA dilewati (mode tidak berubah).';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const r = await require('../services/serviceNoticeNotifyService').broadcastNotice({
|
||||||
|
mode,
|
||||||
|
target: String(req.body.notice_wa_target || 'all'),
|
||||||
|
area: String(req.body.notice_wa_area || ''),
|
||||||
|
routerId: parseInt(req.body.notice_wa_router, 10) || 0,
|
||||||
|
notice: n
|
||||||
|
});
|
||||||
|
if (r.ok) waInfo = ` Notifikasi WA dikirim ke ${r.total} pelanggan (latar belakang).`;
|
||||||
|
else waInfo = ` Notifikasi WA tidak dikirim: ${r.reason === 'empty' ? 'tidak ada nomor valid' : r.reason === 'busy' ? 'proses lain masih berjalan' : 'WhatsApp belum aktif'}.`;
|
||||||
|
} catch (waErr) {
|
||||||
|
waInfo = ' Notifikasi WA gagal: ' + waErr.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.session._msg = { type: 'success', text: 'Status layanan diperbarui: ' + detail + '.' + waInfo };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
req.session._msg = { type: 'error', text: 'Gagal menyimpan status layanan: ' + e.message };
|
req.session._msg = { type: 'error', text: 'Gagal menyimpan status layanan: ' + e.message };
|
||||||
}
|
}
|
||||||
@@ -4405,15 +4541,17 @@ router.post('/automation/send-now', requireAdminSession, express.urlencoded({ ex
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/settings', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
|
router.get('/settings', requireAdminSession, requireSidebarMenuAccess('settings'), (req, res) => {
|
||||||
const settings = getSettings();
|
let settings = getSettings();
|
||||||
|
try { require('../config/settingsManager').ensureWebhookSecret(); settings = getSettings(); } catch (e) {}
|
||||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
||||||
const host = req.get('host');
|
const host = req.get('host');
|
||||||
const baseUrl = (settings && settings.app_url ? String(settings.app_url) : `${protocol}://${host}`).replace(/\/+$/, '');
|
const baseUrl = (settings.public_base_url || (settings && settings.app_url) || `${protocol}://${host}`).toString().replace(/\/+$/, '');
|
||||||
const paymentWebhookUrl = `${baseUrl}/customer/payment/callback`;
|
const paymentWebhookUrl = `${baseUrl}/customer/payment/callback`;
|
||||||
res.render('admin/settings', {
|
res.render('admin/settings', {
|
||||||
title: 'Pengaturan Sistem', company: company(), activePage: 'settings',
|
title: 'Pengaturan Sistem', company: company(), activePage: 'settings',
|
||||||
settings, msg: flashMsg(req),
|
settings, msg: flashMsg(req),
|
||||||
paymentWebhookUrl
|
paymentWebhookUrl,
|
||||||
|
webhookBaseUrl: baseUrl
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4578,16 +4716,34 @@ router.post('/settings/regenerate-icons', requireAdminSession, async (req, res)
|
|||||||
router.get('/update', requireAdminSession, requireSidebarMenuAccess('update'), restrictToAdmin, (req, res) => {
|
router.get('/update', requireAdminSession, requireSidebarMenuAccess('update'), restrictToAdmin, (req, res) => {
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
const info = getUpdateInfo(repoRoot);
|
const info = getUpdateInfo(repoRoot);
|
||||||
|
let restartPending = null;
|
||||||
|
try {
|
||||||
|
const markerPath = path.join(repoRoot, '.restart-required');
|
||||||
|
if (fs.existsSync(markerPath)) restartPending = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||||
|
} catch (e) { restartPending = null; }
|
||||||
res.render('admin/update', {
|
res.render('admin/update', {
|
||||||
title: 'Update Aplikasi',
|
title: 'Update Aplikasi',
|
||||||
company: company(),
|
company: company(),
|
||||||
activePage: 'update',
|
activePage: 'update',
|
||||||
msg: flashMsg(req),
|
msg: flashMsg(req),
|
||||||
info,
|
info,
|
||||||
|
restartPending,
|
||||||
log: popUpdateLog(req)
|
log: popUpdateLog(req)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/api/restart-required', requireAdminSession, (req, res) => {
|
||||||
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
|
try {
|
||||||
|
const markerPath = path.join(repoRoot, '.restart-required');
|
||||||
|
if (fs.existsSync(markerPath)) {
|
||||||
|
const d = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
|
||||||
|
return res.json({ pending: true, version: d.version || '', from: d.from || '', at: d.at || '' });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return res.json({ pending: false });
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
||||||
const repoRoot = path.resolve(__dirname, '..');
|
const repoRoot = path.resolve(__dirname, '..');
|
||||||
if (!runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot).ok) {
|
if (!runCmd('git', ['rev-parse', '--is-inside-work-tree'], repoRoot).ok) {
|
||||||
@@ -4609,6 +4765,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
|||||||
const preservedEntries = [
|
const preservedEntries = [
|
||||||
{ label: 'settings.json', relativePath: 'settings.json', cleanExclude: 'settings.json' },
|
{ label: 'settings.json', relativePath: 'settings.json', cleanExclude: 'settings.json' },
|
||||||
{ label: '.env', relativePath: '.env', cleanExclude: '.env' },
|
{ label: '.env', relativePath: '.env', cleanExclude: '.env' },
|
||||||
|
{ label: '.env.gowa', relativePath: '.env.gowa', cleanExclude: '.env.gowa' },
|
||||||
{ label: 'database', relativePath: 'database', cleanExclude: 'database' },
|
{ label: 'database', relativePath: 'database', cleanExclude: 'database' },
|
||||||
{ label: 'public/uploads', relativePath: 'public/uploads', cleanExclude: 'public/uploads' },
|
{ label: 'public/uploads', relativePath: 'public/uploads', cleanExclude: 'public/uploads' },
|
||||||
{ label: 'public/img', relativePath: 'public/img', cleanExclude: 'public/img' },
|
{ label: 'public/img', relativePath: 'public/img', cleanExclude: 'public/img' },
|
||||||
@@ -4666,9 +4823,9 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
|||||||
if (!inside.ok) throw new Error('Folder ini belum menjadi git repository.');
|
if (!inside.ok) throw new Error('Folder ini belum menjadi git repository.');
|
||||||
|
|
||||||
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
|
const _repo = String(getSetting('update_repo', 'oxpoe/open-isp') || '').trim();
|
||||||
info.repo = _repo || 'origin';
|
const _base = String(getSetting('update_base_url', 'https://gitea.oxpoe.xyz') || '').trim().replace(/\/+$/, '');
|
||||||
const fetch = _repo
|
const fetch = _repo
|
||||||
? runCmd('git', ['fetch', '--prune', 'https://gitea.oxpoe.qzz.io/' + _repo + '.git', branch], repoRoot)
|
? runCmd('git', ['fetch', '--prune', _base + '/' + _repo + '.git', branch], repoRoot, 120000)
|
||||||
: runCmd('git', ['fetch', '--prune'], repoRoot);
|
: runCmd('git', ['fetch', '--prune'], repoRoot);
|
||||||
pushCmd('git fetch --prune', fetch);
|
pushCmd('git fetch --prune', fetch);
|
||||||
if (!fetch.ok) throw new Error('Gagal git fetch.');
|
if (!fetch.ok) throw new Error('Gagal git fetch.');
|
||||||
@@ -4689,11 +4846,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
|||||||
fs.mkdirSync(backupRoot, { recursive: true });
|
fs.mkdirSync(backupRoot, { recursive: true });
|
||||||
backupPreservedEntries();
|
backupPreservedEntries();
|
||||||
|
|
||||||
const resetSettings = runCmd('git', ['checkout', '--', 'settings.json'], repoRoot);
|
// settings.json / database tidak dilacak git; jangan checkout (menghasilkan error pathspec).
|
||||||
pushCmd('git checkout -- settings.json', resetSettings);
|
|
||||||
const resetDb = runCmd('git', ['checkout', '--', 'database'], repoRoot);
|
|
||||||
pushCmd('git checkout -- database', resetDb);
|
|
||||||
|
|
||||||
const resetHard = runCmd('git', ['reset', '--hard', _repo ? 'FETCH_HEAD' : `origin/${branch}`], repoRoot);
|
const resetHard = runCmd('git', ['reset', '--hard', _repo ? 'FETCH_HEAD' : `origin/${branch}`], repoRoot);
|
||||||
pushCmd(`git reset --hard origin/${branch}`, resetHard);
|
pushCmd(`git reset --hard origin/${branch}`, resetHard);
|
||||||
if (!resetHard.ok) throw new Error('Gagal reset ke origin/' + branch);
|
if (!resetHard.ok) throw new Error('Gagal reset ke origin/' + branch);
|
||||||
@@ -4736,7 +4889,17 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localAfter = readTextFileSafe(versionPath) || '-';
|
const localAfter = readTextFileSafe(versionPath) || '-';
|
||||||
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Silakan restart aplikasi.` };
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repoRoot, '.restart-required'),
|
||||||
|
JSON.stringify({ version: localAfter, from: localBefore, at: new Date().toISOString() }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
log.push('$ restart-required marker ditulis');
|
||||||
|
} catch (markerErr) {
|
||||||
|
log.push('$ restart-required marker gagal: ' + String(markerErr?.message || markerErr));
|
||||||
|
}
|
||||||
|
req.session._msg = { type: 'success', text: `Update selesai. Versi: ${localBefore} → ${localAfter}. Restart aplikasi diperlukan agar perubahan aktif.` };
|
||||||
req.session._updateLog = log.join('\n');
|
req.session._updateLog = log.join('\n');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (repoChanged) {
|
if (repoChanged) {
|
||||||
@@ -4746,6 +4909,7 @@ router.post('/update/run', requireAdminSession, restrictToAdmin, (req, res) => {
|
|||||||
log.push(`$ restore preserved files failed: ${String(restoreErr?.message || restoreErr)}`);
|
log.push(`$ restore preserved files failed: ${String(restoreErr?.message || restoreErr)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.push(`$ ERROR: ${e?.message || e}`);
|
||||||
req.session._msg = { type: 'error', text: 'Gagal update: ' + (e?.message || e) };
|
req.session._msg = { type: 'error', text: 'Gagal update: ' + (e?.message || e) };
|
||||||
req.session._updateLog = log.join('\n');
|
req.session._updateLog = log.join('\n');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4767,6 +4931,76 @@ router.post('/api/telegram/sync', requireAdminSession, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/settings/test-ai', requireAdminSession, express.json(), async (req, res) => {
|
||||||
|
const presets = {
|
||||||
|
deepseek: { baseUrl: 'https://api.deepseek.com', model: 'deepseek-chat' },
|
||||||
|
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||||||
|
groq: { baseUrl: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile' },
|
||||||
|
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', model: 'meta-llama/llama-3.3-70b-instruct' },
|
||||||
|
custom: { baseUrl: '', model: '' }
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const b = req.body || {};
|
||||||
|
const provider = String(b.provider || 'deepseek').trim().toLowerCase();
|
||||||
|
const p = presets[provider] || presets.deepseek;
|
||||||
|
const apiKey = String(b.apiKey || '').trim();
|
||||||
|
const baseUrl = String(b.baseUrl || p.baseUrl || '').trim().replace(/\/+$/, '');
|
||||||
|
const model = String(b.model || p.model || '').trim();
|
||||||
|
|
||||||
|
if (!baseUrl) return res.status(400).json({ ok: false, message: 'Base URL AI kosong.' });
|
||||||
|
if (!model) return res.status(400).json({ ok: false, message: 'Model AI kosong.' });
|
||||||
|
if (!apiKey && provider !== 'custom') return res.status(400).json({ ok: false, message: 'API key kosong.' });
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(function () { controller.abort(); }, 15000);
|
||||||
|
let r;
|
||||||
|
let text = '';
|
||||||
|
try {
|
||||||
|
r = await fetch(baseUrl + '/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign(
|
||||||
|
{ 'Content-Type': 'application/json' },
|
||||||
|
apiKey ? { Authorization: 'Bearer ' + apiKey } : {}
|
||||||
|
),
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model,
|
||||||
|
messages: [{ role: 'user', content: 'ping' }],
|
||||||
|
max_tokens: 5,
|
||||||
|
stream: false
|
||||||
|
}),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
text = await r.text();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const latencyMs = Date.now() - started;
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch (e) {}
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const detail = (json && json.error && (json.error.message || json.error.code)) || text || ('HTTP ' + r.status);
|
||||||
|
const message = (r.status === 401 || r.status === 403)
|
||||||
|
? 'API key tidak valid / tidak punya akses (HTTP ' + r.status + ').'
|
||||||
|
: 'Gagal (HTTP ' + r.status + '): ' + String(detail).replace(/\s+/g, ' ').slice(0, 200);
|
||||||
|
return res.json({ ok: false, provider, model, baseUrl, status: r.status, latencyMs, message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = (json && json.choices && json.choices[0] &&
|
||||||
|
((json.choices[0].message && json.choices[0].message.content) || json.choices[0].text)) || '';
|
||||||
|
return res.json({
|
||||||
|
ok: true, provider, model, baseUrl, status: r.status, latencyMs,
|
||||||
|
message: 'API valid — ' + (reply ? ('balasan: ' + String(reply).trim().slice(0, 80)) : 'respon OK') + ' (' + latencyMs + ' ms)'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
const message = e && e.name === 'AbortError'
|
||||||
|
? 'Timeout 15 detik — server AI tidak merespons.'
|
||||||
|
: ('Tidak bisa menghubungi API: ' + (e && e.message ? e.message : String(e)));
|
||||||
|
return res.status(500).json({ ok: false, message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
router.post('/settings', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
||||||
try {
|
try {
|
||||||
const newSettings = { ...req.body };
|
const newSettings = { ...req.body };
|
||||||
@@ -4775,6 +5009,12 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
|
|||||||
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
|
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
|
||||||
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
|
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
|
||||||
|
|
||||||
|
if (newSettings.update_base_url !== undefined) {
|
||||||
|
newSettings.update_base_url = String(newSettings.update_base_url).trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
if (newSettings.update_repo !== undefined) {
|
||||||
|
newSettings.update_repo = String(newSettings.update_repo).trim().replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
newSettings.ai_provider = String(newSettings.ai_provider || 'deepseek').trim().toLowerCase();
|
newSettings.ai_provider = String(newSettings.ai_provider || 'deepseek').trim().toLowerCase();
|
||||||
newSettings.ai_self_healing_enabled = newSettings.ai_self_healing_enabled === 'true' || newSettings.ai_self_healing_enabled === 'on';
|
newSettings.ai_self_healing_enabled = newSettings.ai_self_healing_enabled === 'true' || newSettings.ai_self_healing_enabled === 'on';
|
||||||
for (const k of ['deepseek_api_key','openai_api_key','groq_api_key','openrouter_api_key','custom_ai_api_key','custom_ai_base_url','custom_ai_model','deepseek_model','openai_model','groq_model','openrouter_model']) {
|
for (const k of ['deepseek_api_key','openai_api_key','groq_api_key','openrouter_api_key','custom_ai_api_key','custom_ai_base_url','custom_ai_model','deepseek_model','openai_model','groq_model','openrouter_model']) {
|
||||||
@@ -4829,6 +5069,7 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
|
|||||||
if (newSettings.auto_deactivate_enabled !== undefined) newSettings.auto_deactivate_enabled = (newSettings.auto_deactivate_enabled === 'true');
|
if (newSettings.auto_deactivate_enabled !== undefined) newSettings.auto_deactivate_enabled = (newSettings.auto_deactivate_enabled === 'true');
|
||||||
if (newSettings.auto_deactivate_months !== undefined) newSettings.auto_deactivate_months = parseInt(newSettings.auto_deactivate_months) || 3;
|
if (newSettings.auto_deactivate_months !== undefined) newSettings.auto_deactivate_months = parseInt(newSettings.auto_deactivate_months) || 3;
|
||||||
newSettings.use_builtin_acs = (newSettings.use_builtin_acs === 'true' || newSettings.use_builtin_acs === true);
|
newSettings.use_builtin_acs = (newSettings.use_builtin_acs === 'true' || newSettings.use_builtin_acs === true);
|
||||||
|
newSettings.portal_banner_enabled = (newSettings.portal_banner_enabled === 'true' || newSettings.portal_banner_enabled === true);
|
||||||
|
|
||||||
// Multi-Router Mode settings
|
// Multi-Router Mode settings
|
||||||
if (!newSettings.multi_router_mode) newSettings.multi_router_mode = 'disabled';
|
if (!newSettings.multi_router_mode) newSettings.multi_router_mode = 'disabled';
|
||||||
@@ -4898,6 +5139,29 @@ router.post('/payment-unmatched/:id/match', requireAdminSession, express.urlenco
|
|||||||
if (!unpaid) await customerSvc.activateCustomer(inv.customer_id);
|
if (!unpaid) await customerSvc.activateCustomer(inv.customer_id);
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Notifikasi WA lunas ke pelanggan
|
||||||
|
try {
|
||||||
|
const cust = customerSvc.getCustomerById(inv.customer_id);
|
||||||
|
if (cust && cust.phone) {
|
||||||
|
const pkg = cust.package_id ? customerSvc.getPackageById(cust.package_id) : null;
|
||||||
|
await sendPaymentSuccessWA(
|
||||||
|
cust.phone,
|
||||||
|
cust.name,
|
||||||
|
`${inv.period_month}/${inv.period_year}`,
|
||||||
|
Number(inv.amount || 0).toLocaleString('id-ID'),
|
||||||
|
'Manual (Webhook)',
|
||||||
|
{
|
||||||
|
customerId: cust.id,
|
||||||
|
invoiceId: inv.id,
|
||||||
|
username: cust.pppoe_username || cust.id,
|
||||||
|
customerCode: cust.customer_code || '',
|
||||||
|
packageName: pkg?.name || cust.package_name || '-',
|
||||||
|
paidAt: new Date(), method: 'Manual (Webhook)'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
req.session._msg = { type: 'success', text: 'Invoice #' + invId + ' ditandai LUNAS & notif dicocokkan.' };
|
req.session._msg = { type: 'success', text: 'Invoice #' + invId + ' ditandai LUNAS & notif dicocokkan.' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
req.session._msg = { type: 'error', text: 'Gagal cocokkan: ' + e.message };
|
req.session._msg = { type: 'error', text: 'Gagal cocokkan: ' + e.message };
|
||||||
@@ -6621,10 +6885,22 @@ router.get('/whatsapp/gowa/qr', requireAdminSession, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const gowa = require('../services/gowaWhatsappService');
|
const gowa = require('../services/gowaWhatsappService');
|
||||||
const dev = await gowa.ensureDevice();
|
const dev = await gowa.ensureDevice();
|
||||||
if (dev && dev !== getSetting('gowa_device_id', '')) saveSettings({ gowa_device_id: dev });
|
// Jangan simpan otomatis: device id kosong = ikuti device yang terhubung via QR.
|
||||||
const r = await gowa.getLoginQr(dev);
|
const r = await gowa.getLoginQr(dev);
|
||||||
if (!r.ok) return res.json({ ok: false, message: 'Gagal membuat QR (' + (r.code || 'pastikan GOWA berjalan') + ').' });
|
if (!r.ok) {
|
||||||
return res.json({ ok: true, image: '/admin/whatsapp/gowa/qr.png?v=' + Date.now(), device_id: r.device_id, duration: r.duration });
|
const detail = r.message || r.code || 'pastikan GOWA berjalan & username/password Basic Auth benar';
|
||||||
|
return res.json({ ok: false, status: r.status || 0, message: 'Gagal membuat QR: ' + (r.status ? 'HTTP ' + r.status + ' — ' : '') + detail + '.' });
|
||||||
|
}
|
||||||
|
// Kirim gambar langsung sebagai data URI: tidak bergantung pada request
|
||||||
|
// kedua /qr.png (aman bila app berjalan multi-proses) dan tidak kena cache.
|
||||||
|
let image = '/admin/whatsapp/gowa/qr.png?v=' + Date.now();
|
||||||
|
try {
|
||||||
|
const img = await gowa.getLastQrImage();
|
||||||
|
if (img && img.buffer && img.buffer.length) {
|
||||||
|
image = 'data:' + (img.contentType || 'image/png') + ';base64,' + img.buffer.toString('base64');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return res.json({ ok: true, image, device_id: r.device_id, duration: r.duration });
|
||||||
} catch (e) { return res.json({ ok: false, message: e.message }); }
|
} catch (e) { return res.json({ ok: false, message: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -6652,12 +6928,17 @@ router.get('/whatsapp', requireAdminSession, requireSidebarMenuAccess('whatsapp'
|
|||||||
fonnte_api_url: getSetting('fonnte_api_url', 'https://api.fonnte.com/send'),
|
fonnte_api_url: getSetting('fonnte_api_url', 'https://api.fonnte.com/send'),
|
||||||
fonnte_country_code: getSetting('fonnte_country_code', '62')
|
fonnte_country_code: getSetting('fonnte_country_code', '62')
|
||||||
};
|
};
|
||||||
|
const gowaBaseUrl = getSetting('gowa_base_url', 'http://127.0.0.1:3000');
|
||||||
|
let gowaWebhookSecret = getSetting('gowa_webhook_secret', '');
|
||||||
|
try { gowaWebhookSecret = require('../config/settingsManager').ensureGowaWebhookSecret(); } catch (e) {}
|
||||||
const gowaSettings = {
|
const gowaSettings = {
|
||||||
gowa_base_url: getSetting('gowa_base_url', 'http://127.0.0.1:3000'),
|
gowa_mode: getSetting('gowa_mode', /^https:/i.test(String(gowaBaseUrl)) ? 'cloud' : 'local'),
|
||||||
|
gowa_secret_mode: getSetting('gowa_secret_mode', 'auto') === 'manual' ? 'manual' : 'auto',
|
||||||
|
gowa_base_url: gowaBaseUrl,
|
||||||
gowa_username: getSetting('gowa_username', ''),
|
gowa_username: getSetting('gowa_username', ''),
|
||||||
gowa_password: getSetting('gowa_password', ''),
|
gowa_password: getSetting('gowa_password', ''),
|
||||||
gowa_device_id: getSetting('gowa_device_id', ''),
|
gowa_device_id: getSetting('gowa_device_id', ''),
|
||||||
gowa_webhook_secret: getSetting('gowa_webhook_secret', 'secret')
|
gowa_webhook_secret: gowaWebhookSecret
|
||||||
};
|
};
|
||||||
const gowaWebhookUrl = String(getSetting('app_url', '') || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '') + '/api/webhook/gowa';
|
const gowaWebhookUrl = String(getSetting('app_url', '') || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '') + '/api/webhook/gowa';
|
||||||
const gowaUiHost = String(req.get('host') || '').split(':')[0];
|
const gowaUiHost = String(req.get('host') || '').split(':')[0];
|
||||||
@@ -6690,6 +6971,7 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
|
|||||||
fonnte_token,
|
fonnte_token,
|
||||||
fonnte_api_url,
|
fonnte_api_url,
|
||||||
fonnte_country_code,
|
fonnte_country_code,
|
||||||
|
gowa_mode,
|
||||||
gowa_base_url,
|
gowa_base_url,
|
||||||
gowa_username,
|
gowa_username,
|
||||||
gowa_password,
|
gowa_password,
|
||||||
@@ -6707,11 +6989,15 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
|
|||||||
fonnte_token: String(fonnte_token || '').trim(),
|
fonnte_token: String(fonnte_token || '').trim(),
|
||||||
fonnte_api_url: String(fonnte_api_url || 'https://api.fonnte.com/send').trim(),
|
fonnte_api_url: String(fonnte_api_url || 'https://api.fonnte.com/send').trim(),
|
||||||
fonnte_country_code: String(fonnte_country_code || '62').trim(),
|
fonnte_country_code: String(fonnte_country_code || '62').trim(),
|
||||||
|
gowa_mode: String(gowa_mode || 'cloud').trim() === 'local' ? 'local' : 'cloud',
|
||||||
gowa_base_url: String(gowa_base_url || 'http://127.0.0.1:3000').trim(),
|
gowa_base_url: String(gowa_base_url || 'http://127.0.0.1:3000').trim(),
|
||||||
gowa_username: String(gowa_username || '').trim(),
|
gowa_username: String(gowa_username || '').trim(),
|
||||||
gowa_password: String(gowa_password || '').trim(),
|
gowa_password: String(gowa_password || '').trim(),
|
||||||
gowa_device_id: String(gowa_device_id || '').trim(),
|
gowa_device_id: String(gowa_device_id || '').trim(),
|
||||||
gowa_webhook_secret: String(gowa_webhook_secret || 'secret').trim()
|
gowa_webhook_secret: (function () {
|
||||||
|
const v = String(gowa_webhook_secret || '').trim();
|
||||||
|
return (v && v !== 'secret' && v.length >= 24) ? v : crypto.randomBytes(32).toString('hex');
|
||||||
|
})()
|
||||||
});
|
});
|
||||||
|
|
||||||
if ((wa_gateway_type || 'baileys') === 'baileys') {
|
if ((wa_gateway_type || 'baileys') === 'baileys') {
|
||||||
@@ -6727,6 +7013,46 @@ router.post('/whatsapp/gateway-settings', requireAdminSession, express.urlencode
|
|||||||
res.redirect('/admin/whatsapp');
|
res.redirect('/admin/whatsapp');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/whatsapp/test-gowa-connection', requireAdminSession, express.json(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const gowaSvc = require('../services/gowaWhatsappService');
|
||||||
|
const b = req.body || {};
|
||||||
|
const r = await gowaSvc.testReachability({
|
||||||
|
baseUrl: b.gowa_base_url,
|
||||||
|
username: b.gowa_username,
|
||||||
|
password: b.gowa_password
|
||||||
|
});
|
||||||
|
return res.json(Object.assign({ ok: r.reachable, success: r.reachable }, r));
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, success: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/whatsapp/gowa-secret', requireAdminSession, express.json(), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sm = require('../config/settingsManager');
|
||||||
|
const b = req.body || {};
|
||||||
|
const mode = String(b.mode || '').toLowerCase() === 'manual' ? 'manual' : 'auto';
|
||||||
|
if (mode === 'auto') {
|
||||||
|
const secret = sm.ensureGowaWebhookSecret(true);
|
||||||
|
saveSettings({ gowa_secret_mode: 'auto' });
|
||||||
|
return res.json({ ok: true, mode: 'auto', secret });
|
||||||
|
}
|
||||||
|
const secret = String(b.secret || '').trim();
|
||||||
|
if (secret) {
|
||||||
|
if (secret === 'secret' || secret.length < 16) {
|
||||||
|
return res.status(400).json({ ok: false, message: 'Secret minimal 16 karakter dan tidak boleh "secret".' });
|
||||||
|
}
|
||||||
|
saveSettings({ gowa_webhook_secret: secret, gowa_secret_mode: 'manual' });
|
||||||
|
return res.json({ ok: true, mode: 'manual', saved: true, secret });
|
||||||
|
}
|
||||||
|
saveSettings({ gowa_secret_mode: 'manual' });
|
||||||
|
return res.json({ ok: true, mode: 'manual', saved: false, secret: getSetting('gowa_webhook_secret', '') });
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ ok: false, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
|
router.post('/whatsapp/test-gowa', requireAdminSession, express.json(), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { phone, message } = req.body;
|
const { phone, message } = req.body;
|
||||||
@@ -7207,8 +7533,14 @@ router.post('/whatsapp/auto-billing', requireAdminSession, express.urlencoded({
|
|||||||
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
|
router.post('/whatsapp/run-reminder-now', requireAdminSession, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const r = await require('../services/cronService').runAutoBillingReminder();
|
const r = await require('../services/cronService').runAutoBillingReminder();
|
||||||
|
const skipLabels = {
|
||||||
|
disabled: 'pengingat otomatis sedang OFF',
|
||||||
|
wa_disabled: 'WhatsApp atau notifikasi tagihan sedang OFF',
|
||||||
|
gateway: 'gateway WhatsApp belum siap',
|
||||||
|
no_targets: 'tidak ada pelanggan pada jadwal pengingat hari ini'
|
||||||
|
};
|
||||||
const msg = (r && r.skipped)
|
const msg = (r && r.skipped)
|
||||||
? ('Pengingat dilewati (' + r.skipped + '). Pastikan opsi otomatis & WA aktif.')
|
? ('Pengingat tidak dikirim: ' + (skipLabels[r.skipped] || r.skipped) + '.')
|
||||||
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
|
: ('Pengingat dijalankan: target ' + ((r && r.target) || 0) + ', terkirim ' + ((r && r.sent) || 0) + ', gagal ' + ((r && r.failed) || 0) + '.');
|
||||||
return res.json({ ok: true, message: msg, data: r || null });
|
return res.json({ ok: true, message: msg, data: r || null });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -7234,11 +7566,24 @@ router.get('/api/whatsapp/status', requireAdmin, async (req, res) => {
|
|||||||
} else if (gatewayType === 'gowa') {
|
} else if (gatewayType === 'gowa') {
|
||||||
let st = null;
|
let st = null;
|
||||||
try { st = await require('../services/gowaWhatsappService').gowaStatus(); } catch (e) { st = { ok: false, error: e.message }; }
|
try { st = await require('../services/gowaWhatsappService').gowaStatus(); } catch (e) { st = { ok: false, error: e.message }; }
|
||||||
const rr = (st && st.data && st.data.results) || {};
|
// /devices mengembalikan array (banyak device); /devices/:id/status mengembalikan objek.
|
||||||
const loggedIn = !!(rr.is_logged_in || rr.isConnected || rr.connected);
|
const raw = st && st.data && st.data.results;
|
||||||
const isConn = !!(rr.is_connected || rr.connected);
|
const list = Array.isArray(raw) ? raw : ((raw && typeof raw === 'object') ? [raw] : []);
|
||||||
|
const pick = list.find(function (d) {
|
||||||
|
return d && (d.is_connected || d.is_logged_in || d.connected ||
|
||||||
|
d.state === 'connected' || d.state === 'logged_in');
|
||||||
|
}) || list[0] || {};
|
||||||
|
const loggedIn = !!(pick.is_logged_in || pick.is_connected || pick.connected ||
|
||||||
|
pick.state === 'connected' || pick.state === 'logged_in');
|
||||||
|
const isConn = !!(pick.is_connected || pick.connected || pick.state === 'connected');
|
||||||
const conn = isConn ? 'open' : (loggedIn ? 'connecting' : 'close');
|
const conn = isConn ? 'open' : (loggedIn ? 'connecting' : 'close');
|
||||||
res.json({ connection: conn, gateway: 'gowa', loggedIn: loggedIn, user: { id: 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' }, lastUpdate: new Date().toISOString() });
|
const deviceId = String(pick.device_id || pick.id || '').trim();
|
||||||
|
// device id kosong = selalu ikuti device yang terhubung (tidak dipin otomatis)
|
||||||
|
res.json({
|
||||||
|
connection: conn, gateway: 'gowa', loggedIn: loggedIn, deviceId: deviceId,
|
||||||
|
user: { id: deviceId || 'gowa_gateway', name: loggedIn ? 'GOWA (WhatsApp)' : 'GOWA Gateway' },
|
||||||
|
lastUpdate: new Date().toISOString()
|
||||||
|
});
|
||||||
} else if (gatewayType === 'baileys') {
|
} else if (gatewayType === 'baileys') {
|
||||||
const { whatsappStatus, startWhatsAppBot, isBotRunning } = await import('../services/whatsappBot.mjs');
|
const { whatsappStatus, startWhatsAppBot, isBotRunning } = await import('../services/whatsappBot.mjs');
|
||||||
if (getSetting('whatsapp_enabled', true) && !isBotRunning() && whatsappStatus.connection !== 'open' && whatsappStatus.connection !== 'qr') {
|
if (getSetting('whatsapp_enabled', true) && !isBotRunning() && whatsappStatus.connection !== 'open' && whatsappStatus.connection !== 'qr') {
|
||||||
@@ -7329,9 +7674,12 @@ router.post('/whatsapp/repair-keys', requireAdminSession, async (req, res) => {
|
|||||||
|
|
||||||
// ─── ROUTERS (MULTI-ROUTER) ──────────────────────────────────────────────────
|
// ─── ROUTERS (MULTI-ROUTER) ──────────────────────────────────────────────────
|
||||||
router.get('/routers', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
|
router.get('/routers', requireAdminSession, requireSidebarMenuAccess('mikrotik'), (req, res) => {
|
||||||
|
const _settings = getSettings();
|
||||||
|
const defaultPublicBase = String(_settings.public_base_url || _settings.app_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
|
||||||
res.render('admin/routers', {
|
res.render('admin/routers', {
|
||||||
title: 'Manajemen Router', company: company(), activePage: 'mikrotik',
|
title: 'Manajemen Router', company: company(), activePage: 'mikrotik',
|
||||||
routers: mikrotikService.getAllRouters(), msg: flashMsg(req)
|
routers: mikrotikService.getAllRouters(), msg: flashMsg(req),
|
||||||
|
defaultPublicBase
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -7643,6 +7991,19 @@ router.post('/api/routers/:id/setup-firewall', requireAdmin, async (req, res) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/api/isolir-cloud-script', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await mikrotikService.generateIsolirCloudScript({
|
||||||
|
host: req.query.host,
|
||||||
|
port: req.query.port,
|
||||||
|
profile: req.query.profile
|
||||||
|
});
|
||||||
|
res.json({ success: true, ...data });
|
||||||
|
} catch (e) {
|
||||||
|
res.json({ success: false, error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/api/isolir-portal-script', requireAdmin, async (req, res) => {
|
router.get('/api/isolir-portal-script', requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const data = await mikrotikService.generateIsolirPortalScript();
|
const data = await mikrotikService.generateIsolirPortalScript();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const adminSvc = require('../services/adminService');
|
|||||||
const techSvc = require('../services/techService');
|
const techSvc = require('../services/techService');
|
||||||
const mikrotikService = require('../services/mikrotikService');
|
const mikrotikService = require('../services/mikrotikService');
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('../services/invoiceQrisService');
|
||||||
const QRCode = require('qrcode');
|
const QRCode = require('qrcode');
|
||||||
|
|
||||||
// Helper Token JWT / HMAC
|
// Helper Token JWT / HMAC
|
||||||
@@ -2358,6 +2359,37 @@ router.post('/app/admin/collector-payments/approve', requireAdminApiAuth, async
|
|||||||
if (unpaidCount === 0) {
|
if (unpaidCount === 0) {
|
||||||
try { await customerSvc.activateCustomer(pay.customer_id); } catch (_) {}
|
try { await customerSvc.activateCustomer(pay.customer_id); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notifikasi WA lunas ke pelanggan (gateway aktif)
|
||||||
|
try {
|
||||||
|
const customer = customerSvc.getCustomerById(pay.customer_id);
|
||||||
|
const inv = billingSvc.getInvoiceById(pay.invoice_id);
|
||||||
|
if (customer && customer.phone && inv) {
|
||||||
|
const settings = getSettingsWithCache();
|
||||||
|
const waNotify = require('../services/waNotifyService');
|
||||||
|
const whatsappService = require('../services/whatsappService');
|
||||||
|
const pkg = customer.package_id ? customerSvc.getPackageById(customer.package_id) : null;
|
||||||
|
const appUrl = String(settings.public_base_url || '').replace(/\/$/, '');
|
||||||
|
const template = db.getAppSetting('whatsapp_payment_success_message', '');
|
||||||
|
const msg = whatsappService.formatPaymentSuccessMessage({
|
||||||
|
customerName: customer.name,
|
||||||
|
invoiceId: inv.id,
|
||||||
|
customerUsername: customer.pppoe_username || customer.id || '-',
|
||||||
|
customerCode: customer.customer_code || '',
|
||||||
|
packageName: pkg?.name || customer.package_name || '-',
|
||||||
|
periodMonth: inv.period_month,
|
||||||
|
periodYear: inv.period_year,
|
||||||
|
amount: inv.amount,
|
||||||
|
paymentMethod: collectorLabel,
|
||||||
|
paidAt: new Date(),
|
||||||
|
companyName: settings.company_header || 'OPEN-ISP',
|
||||||
|
companyPhone: settings.company_phone || '',
|
||||||
|
portalUrl: appUrl ? appUrl + '/customer' : '',
|
||||||
|
customTemplate: template
|
||||||
|
});
|
||||||
|
await waNotify.sendText(customer.phone, msg);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
res.json({ success: true, message: 'Pembayaran berhasil di-approve dan tagihan ditandai LUNAS' });
|
res.json({ success: true, message: 'Pembayaran berhasil di-approve dan tagihan ditandai LUNAS' });
|
||||||
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
|
} catch (e) { res.status(500).json({ success: false, message: e.message }); }
|
||||||
@@ -3509,6 +3541,11 @@ router.post('/tickets/create', requireCustomerApiAuth, async (req, res) => {
|
|||||||
for (const digits of recipients) {
|
for (const digits of recipients) {
|
||||||
try { await waNotify.sendText(digits, waMsg); } catch (_) {}
|
try { await waNotify.sendText(digits, waMsg); } catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WA konfirmasi ke pelanggan yang membuat tiket
|
||||||
|
try {
|
||||||
|
await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (waErr) {
|
} catch (waErr) {
|
||||||
logger.error(`[CustomerAPI] Ticket WA Notification Error: ${waErr.message}`);
|
logger.error(`[CustomerAPI] Ticket WA Notification Error: ${waErr.message}`);
|
||||||
@@ -3670,7 +3707,6 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
return { uniqueCode: currentCode, amountUnique: currentAmount };
|
return { uniqueCode: currentCode, amountUnique: currentAmount };
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
let chosenCode = 0;
|
let chosenCode = 0;
|
||||||
let chosenAmount = 0;
|
let chosenAmount = 0;
|
||||||
|
|
||||||
@@ -3678,7 +3714,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
if (custId > 0) {
|
if (custId > 0) {
|
||||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||||
const prefAmount = baseAmount + prefCode;
|
const prefAmount = baseAmount + prefCode;
|
||||||
if (!exists.get('unpaid', prefAmount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(prefAmount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = prefCode;
|
chosenCode = prefCode;
|
||||||
chosenAmount = prefAmount;
|
chosenAmount = prefAmount;
|
||||||
}
|
}
|
||||||
@@ -3688,7 +3724,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 1; code <= 499; code++) {
|
for (let code = 1; code <= 499; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
@@ -3700,7 +3736,7 @@ function ensureCustomerApiInvoiceQrisUnique(inv) {
|
|||||||
if (!chosenAmount) {
|
if (!chosenAmount) {
|
||||||
for (let code = 500; code <= 999; code++) {
|
for (let code = 500; code <= 999; code++) {
|
||||||
const amount = baseAmount + code;
|
const amount = baseAmount + code;
|
||||||
if (!exists.get('unpaid', amount, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(amount, { excludeInvoiceId: invId })) {
|
||||||
chosenCode = code;
|
chosenCode = code;
|
||||||
chosenAmount = amount;
|
chosenAmount = amount;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const QRCode = require('qrcode');
|
|||||||
const _jimpMod = require('jimp');
|
const _jimpMod = require('jimp');
|
||||||
const Jimp = _jimpMod.Jimp || _jimpMod;
|
const Jimp = _jimpMod.Jimp || _jimpMod;
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('../services/invoiceQrisService');
|
||||||
const whatsappService = require('../services/whatsappService');
|
const whatsappService = require('../services/whatsappService');
|
||||||
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
|
const { BinaryBitmap, HybridBinarizer, RGBLuminanceSource, MultiFormatReader, BarcodeFormat, DecodeHintType } = require('@zxing/library');
|
||||||
let loginRateLimiter = (req, res, next) => next();
|
let loginRateLimiter = (req, res, next) => next();
|
||||||
@@ -437,16 +438,10 @@ function getBaseUrl(req, settings) {
|
|||||||
function isQrisAmountAvailable(amount, opts = {}) {
|
function isQrisAmountAvailable(amount, opts = {}) {
|
||||||
const amt = Number(amount || 0);
|
const amt = Number(amount || 0);
|
||||||
if (!Number.isFinite(amt) || amt <= 0) return false;
|
if (!Number.isFinite(amt) || amt <= 0) return false;
|
||||||
const excludeInvoiceId = Number(opts.excludeInvoiceId || 0);
|
return !qrisUniq.isQrisAmountTaken(amt, {
|
||||||
const excludeVoucherOrderId = Number(opts.excludeVoucherOrderId || 0);
|
excludeInvoiceId: opts.excludeInvoiceId,
|
||||||
|
excludeVoucherOrderId: opts.excludeVoucherOrderId
|
||||||
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, excludeInvoiceId);
|
});
|
||||||
if (inv && inv.id) return false;
|
|
||||||
|
|
||||||
const ord = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, excludeVoucherOrderId);
|
|
||||||
if (ord && ord.id) return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/qris/static.jpg', async (req, res) => {
|
router.get('/qris/static.jpg', async (req, res) => {
|
||||||
@@ -3002,6 +2997,11 @@ router.post('/tickets/create', uploadCustomer.array('photos', 5), async (req, re
|
|||||||
if (!shouldSendWa(key)) continue;
|
if (!shouldSendWa(key)) continue;
|
||||||
await waNotify.sendText(digits, waMsg);
|
await waNotify.sendText(digits, waMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WA konfirmasi ke pelanggan yang membuat tiket
|
||||||
|
try {
|
||||||
|
await require('../services/serviceNoticeNotifyService').notifyTicketCreated(customer, ticketId, subject);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
} catch (waErr) {
|
} catch (waErr) {
|
||||||
logger.error(`[Ticket] WA Notification Error: ${waErr.message}`);
|
logger.error(`[Ticket] WA Notification Error: ${waErr.message}`);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function testIsAdminNumber(number) {
|
|||||||
// Test beberapa nomor
|
// Test beberapa nomor
|
||||||
const testNumbers = [
|
const testNumbers = [
|
||||||
'6281947215703',
|
'6281947215703',
|
||||||
'081947215703',
|
|
||||||
'083807665697',
|
'083807665697',
|
||||||
'082218094778',
|
'082218094778',
|
||||||
'081234567890' // Nomor test yang bukan admin
|
'081234567890' // Nomor test yang bukan admin
|
||||||
|
|||||||
@@ -40,15 +40,19 @@ function spintax(t) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMessage(tplRaw, c, inv) {
|
function buildMessage(tplRaw, c, inv, qr) {
|
||||||
const amount = Number(inv.amount || 0);
|
const amount = Number(inv.amount || 0);
|
||||||
|
const qrisAmount = Number((qr && qr.amount) || 0) || amount;
|
||||||
|
const qrisCode = Number((qr && qr.uniqueCode) || 0) || 0;
|
||||||
|
const qrisAttached = !!(qr && qr.payload);
|
||||||
const period = inv.period_month + '/' + inv.period_year;
|
const period = inv.period_month + '/' + inv.period_year;
|
||||||
return spintax(String(tplRaw || '')
|
return spintax(String(tplRaw || '')
|
||||||
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
|
.replace(/{{id_pelanggan}}/gi, c.customer_code || ('ID:' + c.id))
|
||||||
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
|
.replace(/{{nama}}/gi, c.name || 'Pelanggan')
|
||||||
.replace(/{{paket}}/gi, inv.package_name || '-')
|
.replace(/{{paket}}/gi, inv.package_name || '-')
|
||||||
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
|
.replace(/{{tagihan}}/gi, amount.toLocaleString('id-ID'))
|
||||||
.replace(/{{qris_nominal}}/gi, amount.toLocaleString('id-ID'))
|
.replace(/{{qris_nominal}}/gi, qrisAmount.toLocaleString('id-ID'))
|
||||||
|
.replace(/{{qris_kode}}/gi, String(qrisCode).padStart(3, '0'))
|
||||||
.replace(/{{rincian}}/gi, period)
|
.replace(/{{rincian}}/gi, period)
|
||||||
.replace(/{{periode}}/gi, period)
|
.replace(/{{periode}}/gi, period)
|
||||||
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
.replace(/{{bulan_nunggak}}/gi, String(c.unpaid_streak || 0))
|
||||||
@@ -57,7 +61,7 @@ function buildMessage(tplRaw, c, inv) {
|
|||||||
.replace(/{{link_portal}}/gi, portalLink())
|
.replace(/{{link_portal}}/gi, portalLink())
|
||||||
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
.replace(/{{company}}/gi, getSetting('company_header', '') || '-')
|
||||||
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
|
.replace(/{{company_phone}}/gi, getSetting('company_phone', '') || '-')
|
||||||
.replace(/{{qris_qr}}/gi, 'QRIS terlampir'));
|
.replace(/{{qris_qr}}/gi, qrisAttached ? 'QRIS terlampir' : ('Portal Pelanggan: ' + portalLink())));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTargets(isolateDay) {
|
function buildTargets(isolateDay) {
|
||||||
@@ -72,6 +76,7 @@ function buildTargets(isolateDay) {
|
|||||||
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
|
try { invs = billingSvc.getUnpaidInvoicesByCustomerId(c.id) || []; } catch (e) {}
|
||||||
if (!invs.length) continue;
|
if (!invs.length) continue;
|
||||||
if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue;
|
if (!c.phone || String(c.phone).replace(/\D/g, '').length < 9) continue;
|
||||||
|
// skema hangus: hanya tagihan terbaru yang valid; tagihan lama otomatis void saat tagihan baru terbit
|
||||||
targets.push({ c, inv: invs[invs.length - 1] });
|
targets.push({ c, inv: invs[invs.length - 1] });
|
||||||
}
|
}
|
||||||
return targets;
|
return targets;
|
||||||
@@ -108,26 +113,22 @@ async function start(opts = {}) {
|
|||||||
|
|
||||||
const waNotify = require('./waNotifyService');
|
const waNotify = require('./waNotifyService');
|
||||||
let QRCode = null, ensureInvoiceQris = null;
|
let QRCode = null, ensureInvoiceQris = null;
|
||||||
if (isQris) { try { QRCode = require('/opt/billing-rtrw/node_modules/qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
|
if (isQris) { try { QRCode = require('qrcode'); } catch (e) {} try { ensureInvoiceQris = require('./invoiceQrisService').ensureInvoiceQris; } catch (e) {} }
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
for (let i = 0; i < targets.length; i++) {
|
for (let i = 0; i < targets.length; i++) {
|
||||||
if (STATUS.stopped) break;
|
if (STATUS.stopped) break;
|
||||||
const { c, inv } = targets[i];
|
const { c, inv } = targets[i];
|
||||||
try {
|
try {
|
||||||
const caption = buildMessage(tplRaw, c, inv);
|
const qr = (isQris && ensureInvoiceQris) ? (ensureInvoiceQris(inv) || {}) : null;
|
||||||
if (isQris && QRCode && ensureInvoiceQris) {
|
const caption = buildMessage(tplRaw, c, inv, qr);
|
||||||
const qr = ensureInvoiceQris(inv) || {};
|
if (isQris && QRCode && qr && qr.payload) {
|
||||||
if (qr.payload) {
|
|
||||||
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
|
const url = await QRCode.toDataURL(qr.payload, { errorCorrectionLevel: 'M', margin: 1, width: 520 });
|
||||||
const buf = Buffer.from(String(url).split(',')[1], 'base64');
|
const buf = Buffer.from(String(url).split(',')[1], 'base64');
|
||||||
await waNotify.sendImage(c.phone, buf, caption);
|
await waNotify.sendImage(c.phone, buf, caption);
|
||||||
} else {
|
} else {
|
||||||
await waNotify.sendText(c.phone, caption);
|
await waNotify.sendText(c.phone, caption);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await waNotify.sendText(c.phone, caption);
|
|
||||||
}
|
|
||||||
STATUS.sent++; STATUS.last = c.name;
|
STATUS.sent++; STATUS.last = c.name;
|
||||||
} catch (e) { STATUS.failed++; STATUS.last = c.name + ' (gagal)'; }
|
} catch (e) { STATUS.failed++; STATUS.last = c.name + ' (gagal)'; }
|
||||||
if (i < targets.length - 1 && !STATUS.stopped) {
|
if (i < targets.length - 1 && !STATUS.stopped) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const usageSvc = require('./usageService');
|
|||||||
const { getSetting } = require('../config/settingsManager');
|
const { getSetting } = require('../config/settingsManager');
|
||||||
const db = require('../config/database');
|
const db = require('../config/database');
|
||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
|
const qrisUniq = require('./invoiceQrisService');
|
||||||
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
|
const { formatUnpaidInvoicesSummary } = require('./whatsappService');
|
||||||
|
|
||||||
// Helper: Random delay generator untuk smart rate limiting
|
// Helper: Random delay generator untuk smart rate limiting
|
||||||
@@ -60,7 +61,7 @@ async function runAutoBillingReminder() {
|
|||||||
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
|
const enabled = getSetting('whatsapp_auto_billing_enabled', false);
|
||||||
const waEnabled = getSetting('whatsapp_enabled', false);
|
const waEnabled = getSetting('whatsapp_enabled', false);
|
||||||
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
|
const billingEnabled = getSetting('whatsapp_billing_to_customer_enabled', true);
|
||||||
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: 'disabled' };
|
if (!enabled || !waEnabled || !billingEnabled) return { target: 0, sent: 0, failed: 0, skipped: (!waEnabled || !billingEnabled) ? 'wa_disabled' : 'disabled' };
|
||||||
|
|
||||||
const gatewayType = getSetting('wa_gateway_type', 'gowa');
|
const gatewayType = getSetting('wa_gateway_type', 'gowa');
|
||||||
const waSvc = require('./whatsappService');
|
const waSvc = require('./whatsappService');
|
||||||
@@ -70,13 +71,13 @@ async function runAutoBillingReminder() {
|
|||||||
const token = getSetting('meta_access_token', '');
|
const token = getSetting('meta_access_token', '');
|
||||||
if (!phoneId || !token) {
|
if (!phoneId || !token) {
|
||||||
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
|
logger.warn('[CRON] Kredensial Meta API belum diisi, pengingat tagihan otomatis dilewati.');
|
||||||
return;
|
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||||
}
|
}
|
||||||
} else if (gatewayType === 'fonnte') {
|
} else if (gatewayType === 'fonnte') {
|
||||||
const token = getSetting('fonnte_token', '');
|
const token = getSetting('fonnte_token', '');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
|
logger.warn('[CRON] Token Fonnte belum diisi, pengingat tagihan otomatis dilewati.');
|
||||||
return;
|
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
|
// Gateway gowa/baileys/lainnya → cek koneksi gateway aktif (gateway-aware)
|
||||||
@@ -84,7 +85,7 @@ async function runAutoBillingReminder() {
|
|||||||
const connected = await waNotify.isConnected().catch(() => false);
|
const connected = await waNotify.isConnected().catch(() => false);
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
|
logger.warn('[CRON] WhatsApp gateway belum terhubung, pengingat tagihan otomatis dilewati.');
|
||||||
return;
|
return { target: 0, sent: 0, failed: 0, skipped: 'gateway' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,14 +214,13 @@ async function runAutoBillingReminder() {
|
|||||||
|
|
||||||
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
|
// Jika amt tidak ada atau amt tidak cocok dengan akumulasi total tagihan + kode, buat/perbarui nominal unik
|
||||||
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
|
if ((!code || !amt || (amt - code !== baseAmount)) && invId > 0 && baseAmount > 0) {
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
const custId = Number(c?.id || inv?.customer_id || 0);
|
const custId = Number(c?.id || inv?.customer_id || 0);
|
||||||
let tryCode = 0;
|
let tryCode = 0;
|
||||||
let tryAmt = 0;
|
let tryAmt = 0;
|
||||||
|
|
||||||
if (code > 0) {
|
if (code > 0) {
|
||||||
const pAmt = baseAmount + code;
|
const pAmt = baseAmount + code;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
tryCode = code;
|
tryCode = code;
|
||||||
tryAmt = pAmt;
|
tryAmt = pAmt;
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,7 @@ async function runAutoBillingReminder() {
|
|||||||
if (!tryAmt && custId > 0) {
|
if (!tryAmt && custId > 0) {
|
||||||
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
const prefCode = (custId % 499 === 0) ? 499 : (custId % 499);
|
||||||
const pAmt = baseAmount + prefCode;
|
const pAmt = baseAmount + prefCode;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
tryCode = prefCode;
|
tryCode = prefCode;
|
||||||
tryAmt = pAmt;
|
tryAmt = pAmt;
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@ async function runAutoBillingReminder() {
|
|||||||
if (!tryAmt) {
|
if (!tryAmt) {
|
||||||
for (let randCode = 1; randCode <= 499; randCode++) {
|
for (let randCode = 1; randCode <= 499; randCode++) {
|
||||||
const pAmt = baseAmount + randCode;
|
const pAmt = baseAmount + randCode;
|
||||||
if (!exists.get('unpaid', pAmt, invId)) {
|
if (!qrisUniq.isQrisAmountTaken(pAmt, { excludeInvoiceId: invId })) {
|
||||||
tryCode = randCode;
|
tryCode = randCode;
|
||||||
tryAmt = pAmt;
|
tryAmt = pAmt;
|
||||||
break;
|
break;
|
||||||
|
|||||||
235
services/customQrService.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* customQrService.js — QR nominal bebas (di luar billing) dengan kode unik 20 menit.
|
||||||
|
* Status: pending -> paid | expired -> warning | rejected
|
||||||
|
*/
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const db = require('../config/database');
|
||||||
|
const { isQrisAmountTaken } = require('./invoiceQrisService');
|
||||||
|
const { logger } = require('../config/logger');
|
||||||
|
const { getSetting } = require('../config/settingsManager');
|
||||||
|
|
||||||
|
const DURATION_MINUTES = 20;
|
||||||
|
|
||||||
|
function isReady() {
|
||||||
|
try {
|
||||||
|
const enabled = getSetting('qris_static_enabled', true);
|
||||||
|
const off = enabled === false || enabled === 'false' || enabled === 0 || enabled === '0';
|
||||||
|
const payload = String(getSetting('qris_static_payload', '') || '').trim();
|
||||||
|
return !off && payload.length > 0;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowIso() { return new Date().toISOString(); }
|
||||||
|
|
||||||
|
function sweepExpired() {
|
||||||
|
try {
|
||||||
|
db.prepare("UPDATE custom_qr_payments SET status='expired' WHERE status='pending' AND expires_at IS NOT NULL AND expires_at < ?").run(nowIso());
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaken(amount, excludeId) {
|
||||||
|
if (isQrisAmountTaken(amount, { excludeCustomQrId: excludeId || 0 })) return true;
|
||||||
|
try {
|
||||||
|
const row = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amount, Number(excludeId) || 0);
|
||||||
|
return !!(row && row.id);
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function create({ title, category, amount, note, createdBy }) {
|
||||||
|
if (!isReady()) throw new Error('QRIS Statis (Semi-Otomatis) belum aktif/berisi payload. Atur dulu di Pengaturan → QRIS.');
|
||||||
|
const base = Math.max(1, Math.floor(Number(amount) || 0));
|
||||||
|
if (!title || !String(title).trim()) throw new Error('Judul wajib diisi.');
|
||||||
|
if (!base) throw new Error('Nominal wajib diisi.');
|
||||||
|
|
||||||
|
let code = 0;
|
||||||
|
let amountUnique = 0;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
const c = 1 + Math.floor(Math.random() * 999);
|
||||||
|
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
|
||||||
|
}
|
||||||
|
if (!amountUnique) {
|
||||||
|
for (let c = 1; c <= 999; c++) {
|
||||||
|
if (!isTaken(base + c)) { code = c; amountUnique = base + c; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!amountUnique) throw new Error('Gagal membuat kode unik (slot 1-999 penuh).');
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(12).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + DURATION_MINUTES * 60 * 1000).toISOString();
|
||||||
|
const r = db.prepare(`
|
||||||
|
INSERT INTO custom_qr_payments (title, category, amount, unique_code, amount_unique, status, share_token, note, created_by, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
|
||||||
|
`).run(String(title).trim(), String(category || '').trim(), base, code, amountUnique, token, String(note || '').trim(), String(createdBy || ''), expiresAt);
|
||||||
|
|
||||||
|
return getById(Number(r.lastInsertRowid));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getById(id) {
|
||||||
|
sweepExpired();
|
||||||
|
return db.prepare('SELECT * FROM custom_qr_payments WHERE id=?').get(Number(id) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getByToken(token) {
|
||||||
|
sweepExpired();
|
||||||
|
return db.prepare('SELECT * FROM custom_qr_payments WHERE share_token=?').get(String(token || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(limit = 100, opts = {}) {
|
||||||
|
sweepExpired();
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
if (opts.from) { where.push('date(created_at) >= date(?)'); params.push(String(opts.from)); }
|
||||||
|
if (opts.to) { where.push('date(created_at) <= date(?)'); params.push(String(opts.to)); }
|
||||||
|
const sql = 'SELECT * FROM custom_qr_payments' +
|
||||||
|
(where.length ? (' WHERE ' + where.join(' AND ')) : '') +
|
||||||
|
' ORDER BY id DESC LIMIT ?';
|
||||||
|
params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
|
||||||
|
return db.prepare(sql).all(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stats() {
|
||||||
|
sweepExpired();
|
||||||
|
const paid = db.prepare("SELECT COUNT(*) c, COALESCE(SUM(amount_unique),0) t FROM custom_qr_payments WHERE status='paid'").get();
|
||||||
|
const pending = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='pending'").get();
|
||||||
|
const warning = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='warning'").get();
|
||||||
|
const rejected = db.prepare("SELECT COUNT(*) c FROM custom_qr_payments WHERE status='rejected'").get();
|
||||||
|
return {
|
||||||
|
paidCount: Number(paid.c || 0),
|
||||||
|
paidTotal: Number(paid.t || 0),
|
||||||
|
pendingCount: Number(pending.c || 0),
|
||||||
|
warningCount: Number(warning.c || 0),
|
||||||
|
rejectedCount: Number(rejected.c || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidatesByAmount(amount) {
|
||||||
|
sweepExpired();
|
||||||
|
return db.prepare("SELECT * FROM custom_qr_payments WHERE amount_unique=? AND status IN ('pending','expired') ORDER BY id DESC LIMIT 2").all(Number(amount) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markManualPaid(id, actor, note) {
|
||||||
|
const row = getById(id);
|
||||||
|
if (!row) throw new Error('Data tidak ditemukan.');
|
||||||
|
if (row.status === 'paid') return row;
|
||||||
|
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolution_note=? WHERE id=?")
|
||||||
|
.run(String(actor || 'Admin'), String(note || 'Manual pay'), row.id);
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptWarning(id, actor, note) {
|
||||||
|
const row = getById(id);
|
||||||
|
if (!row) throw new Error('Data tidak ditemukan.');
|
||||||
|
if (row.status !== 'warning') throw new Error('Hanya pembayaran berstatus warning yang bisa diselesaikan.');
|
||||||
|
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by=?, resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
|
||||||
|
.run(String(actor || 'Admin'), String(actor || 'Admin'), String(note || 'Diterima setelah 20 menit'), row.id);
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id) {
|
||||||
|
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id=?').run(Number(id) || 0);
|
||||||
|
return r.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMany(ids) {
|
||||||
|
const list = (Array.isArray(ids) ? ids : [ids])
|
||||||
|
.map(function (x) { return Number(x); })
|
||||||
|
.filter(function (n) { return Number.isFinite(n) && n > 0; });
|
||||||
|
if (!list.length) return 0;
|
||||||
|
const marks = list.map(function () { return '?'; }).join(',');
|
||||||
|
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id IN (' + marks + ')').run(...list);
|
||||||
|
return r.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
const r = db.prepare('DELETE FROM custom_qr_payments').run();
|
||||||
|
return r.changes || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function regenerate(id, actor) {
|
||||||
|
const old = getById(id);
|
||||||
|
if (!old) throw new Error('Data tidak ditemukan.');
|
||||||
|
if (old.status === 'pending' && !isExpired(old)) throw new Error('QR masih aktif. Tunggu kedaluwarsa atau gunakan QR yang ada.');
|
||||||
|
const row = create({
|
||||||
|
title: old.title,
|
||||||
|
category: old.category,
|
||||||
|
amount: old.amount,
|
||||||
|
note: old.note,
|
||||||
|
createdBy: actor
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(id, actor, note) {
|
||||||
|
const row = getById(id);
|
||||||
|
if (!row) throw new Error('Data tidak ditemukan.');
|
||||||
|
if (row.status === 'rejected') return row;
|
||||||
|
db.prepare("UPDATE custom_qr_payments SET status='rejected', resolved_by=?, resolved_at=NOW_LOCAL(), resolution_note=? WHERE id=?")
|
||||||
|
.run(String(actor || 'Admin'), String(note || 'Refund / ditolak'), row.id);
|
||||||
|
return getById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyAdminPayment(row, isWarning) {
|
||||||
|
try {
|
||||||
|
const waNotify = require('./waNotifyService');
|
||||||
|
const text = isWarning
|
||||||
|
? `⚠️ *PEMBAYARAN QR CUSTOM (LEWAT 20 MENIT)*\n\n` +
|
||||||
|
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
|
||||||
|
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n` +
|
||||||
|
`Dibuat: ${row.created_at}\n\n` +
|
||||||
|
`Nominal persis masuk setelah batas waktu. Buka menu QR-Custom Payment untuk menyelesaikan atau menolak (refund).`
|
||||||
|
: `✅ *PEMBAYARAN QR CUSTOM DITERIMA*\n\n` +
|
||||||
|
`Judul: ${row.title}\nKategori: ${row.category || '-'}\n` +
|
||||||
|
`Nominal: Rp ${Number(row.amount_unique || 0).toLocaleString('id-ID')}\n\n` +
|
||||||
|
`Pembayaran cocok dalam 20 menit.`;
|
||||||
|
await waNotify.notifyAdmin(text);
|
||||||
|
} catch (e) { logger.warn('[CustomQR] notifyAdmin gagal: ' + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPaidFromWebhook(id, notifId) {
|
||||||
|
const row = getById(id);
|
||||||
|
if (!row) return null;
|
||||||
|
db.prepare("UPDATE custom_qr_payments SET status='paid', paid_at=NOW_LOCAL(), paid_by='Webhook', notif_id=? WHERE id=?")
|
||||||
|
.run(notifId || null, row.id);
|
||||||
|
const fresh = getById(id);
|
||||||
|
notifyAdminPayment(fresh, false).catch(() => {});
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markWarningFromWebhook(id, notifId) {
|
||||||
|
const row = getById(id);
|
||||||
|
if (!row) return null;
|
||||||
|
db.prepare("UPDATE custom_qr_payments SET status='warning', warning_at=NOW_LOCAL(), notif_id=? WHERE id=?")
|
||||||
|
.run(notifId || null, row.id);
|
||||||
|
const fresh = getById(id);
|
||||||
|
notifyAdminPayment(fresh, true).catch(() => {});
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpired(row) {
|
||||||
|
if (!row || !row.expires_at) return false;
|
||||||
|
const t = new Date(row.expires_at).getTime();
|
||||||
|
return Number.isFinite(t) && t < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DURATION_MINUTES,
|
||||||
|
isReady,
|
||||||
|
create,
|
||||||
|
list,
|
||||||
|
stats,
|
||||||
|
getById,
|
||||||
|
getByToken,
|
||||||
|
candidatesByAmount,
|
||||||
|
markManualPaid,
|
||||||
|
acceptWarning,
|
||||||
|
regenerate,
|
||||||
|
remove,
|
||||||
|
removeMany,
|
||||||
|
clearAll,
|
||||||
|
reject,
|
||||||
|
markPaidFromWebhook,
|
||||||
|
markWarningFromWebhook,
|
||||||
|
isExpired,
|
||||||
|
sweepExpired
|
||||||
|
};
|
||||||
@@ -112,6 +112,34 @@ async function resolveDeviceToken(input) {
|
|||||||
const found = await findDeviceWithTagVariants(token);
|
const found = await findDeviceWithTagVariants(token);
|
||||||
if (found && found.device && found.device._id) return found.device;
|
if (found && found.device && found.device._id) return found.device;
|
||||||
|
|
||||||
|
// Fallback: cari lewat data pelanggan (ID, HP, PPPoE, tag ACS, atau MAC).
|
||||||
|
try {
|
||||||
|
const db = require('../config/database');
|
||||||
|
const cust = db.prepare(`
|
||||||
|
SELECT pppoe_username, genieacs_tag, mac_address, phone
|
||||||
|
FROM customers
|
||||||
|
WHERE phone = ? OR id = ? OR pppoe_username = ? OR genieacs_tag = ? OR mac_address = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(token, token, token, token, token);
|
||||||
|
|
||||||
|
if (cust) {
|
||||||
|
const candidates = Array.from(new Set([
|
||||||
|
cust.pppoe_username, cust.genieacs_tag, cust.mac_address, cust.phone
|
||||||
|
].map(v => String(v || '').trim()).filter(v => v && v !== token)));
|
||||||
|
|
||||||
|
for (const cand of candidates) {
|
||||||
|
const byPppoe = await findDeviceByPppoe(cand);
|
||||||
|
if (byPppoe && byPppoe._id) return byPppoe;
|
||||||
|
const byTag = await findDeviceByTag(cand);
|
||||||
|
if (byTag && byTag._id) return byTag;
|
||||||
|
const variant = await findDeviceWithTagVariants(cand);
|
||||||
|
if (variant && variant.device && variant.device._id) return variant.device;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug(`[resolveDeviceToken] Customer DB fallback error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +160,7 @@ const parameterPaths = {
|
|||||||
'Device.DeviceInfo.SoftwareVersion'
|
'Device.DeviceInfo.SoftwareVersion'
|
||||||
],
|
],
|
||||||
ssid: [
|
ssid: [
|
||||||
|
'VirtualParameters.WlanSSID',
|
||||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
|
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
|
||||||
'Device.WiFi.SSID.1.SSID',
|
'Device.WiFi.SSID.1.SSID',
|
||||||
'Device.WiFi.SSID.2.SSID'
|
'Device.WiFi.SSID.2.SSID'
|
||||||
@@ -166,6 +195,8 @@ const parameterPaths = {
|
|||||||
pppoeIP: [
|
pppoeIP: [
|
||||||
'VirtualParameters.pppoeIP',
|
'VirtualParameters.pppoeIP',
|
||||||
'VirtualParameters.pppIP',
|
'VirtualParameters.pppIP',
|
||||||
|
'VirtualParameters.WanIP',
|
||||||
|
'VirtualParameters.IPTR069',
|
||||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
|
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
|
||||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress',
|
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress',
|
||||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.ExternalIPAddress',
|
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.2.WANPPPConnection.1.ExternalIPAddress',
|
||||||
@@ -176,8 +207,10 @@ const parameterPaths = {
|
|||||||
'Device.IP.Interface.1.IPv4Address.1.IPAddress'
|
'Device.IP.Interface.1.IPv4Address.1.IPAddress'
|
||||||
],
|
],
|
||||||
pppUsername: [
|
pppUsername: [
|
||||||
|
'VirtualParameters.pppoeUsername2',
|
||||||
'VirtualParameters.pppoeUsername',
|
'VirtualParameters.pppoeUsername',
|
||||||
'VirtualParameters.pppUsername',
|
'VirtualParameters.pppUsername',
|
||||||
|
'VirtualParameters.PPPoEUser',
|
||||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
|
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
|
||||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.2.Username',
|
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.2.Username',
|
||||||
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.3.Username',
|
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.3.Username',
|
||||||
@@ -696,11 +729,19 @@ function mapDeviceData(device, tag, isPppoeActive = false) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
let rxPower = getParameterWithPaths(device, parameterPaths.rxPower);
|
let rxPower = getParameterWithPaths(device, parameterPaths.rxPower);
|
||||||
if (rxPower !== 'N/A' && rxPower !== '-' && rxPower !== '') {
|
if (rxPower !== 'N/A' && rxPower !== '-' && rxPower !== '' && rxPower !== null && rxPower !== undefined) {
|
||||||
const num = parseFloat(rxPower);
|
const raw = parseFloat(rxPower);
|
||||||
if (!isNaN(num) && num > 0) {
|
if (!isNaN(raw)) {
|
||||||
const dbVal = 30 + (Math.log10(num * Math.pow(10, -7)) * 10);
|
if (raw === 0 || raw >= 65535) {
|
||||||
rxPower = (Math.ceil(dbVal * 100) / 100).toFixed(2);
|
rxPower = 'LOS';
|
||||||
|
} else if (raw < 0 && raw > -50) {
|
||||||
|
rxPower = raw.toFixed(2);
|
||||||
|
} else if (raw <= -50) {
|
||||||
|
rxPower = (raw / 100).toFixed(2);
|
||||||
|
} else if (raw > 0) {
|
||||||
|
const dbm = 30 + (Math.log10(raw * 1e-7) * 10);
|
||||||
|
rxPower = (Math.round(dbm * 100) / 100).toFixed(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pppoeIP = extractPppoeIp(device);
|
const pppoeIP = extractPppoeIp(device);
|
||||||
@@ -851,6 +892,7 @@ async function updateSSID(tag, newSSID, actor = null) {
|
|||||||
try {
|
try {
|
||||||
const ssidClean = String(newSSID ?? '').replace(/[\r\n\t]+/g, '').trim();
|
const ssidClean = String(newSSID ?? '').replace(/[\r\n\t]+/g, '').trim();
|
||||||
if (!ssidClean) { logger.warn('[updateSSID] SSID kosong, dibatalkan.'); return false; }
|
if (!ssidClean) { logger.warn('[updateSSID] SSID kosong, dibatalkan.'); return false; }
|
||||||
|
newSSID = ssidClean;
|
||||||
const device = await resolveDeviceToken(tag);
|
const device = await resolveDeviceToken(tag);
|
||||||
if (!device) return false;
|
if (!device) return false;
|
||||||
const deviceId = encodeURIComponent(device._id);
|
const deviceId = encodeURIComponent(device._id);
|
||||||
@@ -863,62 +905,96 @@ async function updateSSID(tag, newSSID, actor = null) {
|
|||||||
const tasksUrl = `/devices/${deviceId}/tasks`;
|
const tasksUrl = `/devices/${deviceId}/tasks`;
|
||||||
|
|
||||||
const parameterValues = [];
|
const parameterValues = [];
|
||||||
|
const igd = device.InternetGatewayDevice;
|
||||||
|
const dev181 = device.Device;
|
||||||
|
|
||||||
// Check supported paths in DB
|
// Check supported paths directly from loaded device object
|
||||||
|
if (igd && igd.LANDevice && igd.LANDevice['1'] && igd.LANDevice['1'].WLANConfiguration) {
|
||||||
|
const wlan = igd.LANDevice['1'].WLANConfiguration;
|
||||||
|
// Selalu ubah SSID 1 (2.4GHz Utama)
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
|
||||||
|
|
||||||
|
// HANYA jika parameter SSID 5G (WLANConfiguration.5) benar-benar ada di perangkat.
|
||||||
|
// JANGAN PERNAH mengubah WLANConfiguration.2 (SSID 2) karena itu adalah SSID2/Guest pada perangkat single-band!
|
||||||
|
const hasFiveG = Boolean(
|
||||||
|
wlan['5'] && (
|
||||||
|
wlan['5'].SSID !== undefined ||
|
||||||
|
wlan['5']._object === true ||
|
||||||
|
wlan['5'].Enable !== undefined ||
|
||||||
|
wlan['5'].KeyPassphrase !== undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (hasFiveG) {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID', `${newSSID}-5G`, 'xsd:string']);
|
||||||
|
}
|
||||||
|
} else if (dev181 && dev181.WiFi) {
|
||||||
|
parameterValues.push(['Device.WiFi.SSID.1.SSID', newSSID, 'xsd:string']);
|
||||||
|
} else {
|
||||||
|
// Fallback check in local DB
|
||||||
const db = require('../config/database');
|
const db = require('../config/database');
|
||||||
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
|
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
|
||||||
const flatParams = row && row.params ? JSON.parse(row.params) : null;
|
const flatParams = row && row.params ? JSON.parse(row.params) : null;
|
||||||
|
|
||||||
if (flatParams) {
|
if (flatParams) {
|
||||||
// SSID 2.4G paths
|
if (flatParams['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID'] !== undefined) {
|
||||||
const paths24G = [
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
|
||||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
|
} else if (flatParams['Device.WiFi.SSID.1.SSID'] !== undefined) {
|
||||||
'Device.WiFi.SSID.1.SSID'
|
parameterValues.push(['Device.WiFi.SSID.1.SSID', newSSID, 'xsd:string']);
|
||||||
];
|
|
||||||
paths24G.forEach(p => {
|
|
||||||
if (flatParams[p] !== undefined) {
|
|
||||||
parameterValues.push([p, ssidClean, 'xsd:string']);
|
|
||||||
}
|
}
|
||||||
});
|
if (flatParams['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID'] !== undefined) {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.SSID', `${newSSID}-5G`, 'xsd:string']);
|
||||||
// SSID 5G paths
|
|
||||||
const paths5G = [
|
|
||||||
'Device.WiFi.SSID.2.SSID'
|
|
||||||
];
|
|
||||||
for (const idx of [5, 6, 7, 8]) {
|
|
||||||
paths5G.push(`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.SSID`);
|
|
||||||
}
|
}
|
||||||
paths5G.forEach(p => {
|
|
||||||
if (flatParams[p] !== undefined) {
|
|
||||||
parameterValues.push([p, `${ssidClean}-5G`, 'xsd:string']);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if no parameters match or device not bootstrapped yet
|
|
||||||
if (parameterValues.length === 0) {
|
if (parameterValues.length === 0) {
|
||||||
parameterValues.push(
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', newSSID, 'xsd:string']);
|
||||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID', ssidClean, 'xsd:string'],
|
}
|
||||||
['Device.WiFi.SSID.1.SSID', ssidClean, 'xsd:string']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let ok = false;
|
let ok = false;
|
||||||
|
for (const pv of parameterValues) {
|
||||||
try {
|
try {
|
||||||
await instance.post(tasksUrl, {
|
await instance.post(tasksUrl, {
|
||||||
name: 'setParameterValues',
|
name: 'setParameterValues',
|
||||||
parameterValues: parameterValues
|
parameterValues: [pv]
|
||||||
}, { timeout: 20000 });
|
}, { timeout: 15000 });
|
||||||
ok = true;
|
ok = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`[updateSSID] Failed to set SSID: ${e.message}`);
|
logger.error(`[updateSSID] Failed to set parameter ${pv[0]}: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh + inform dijalankan di latar belakang agar respons cepat (tidak menahan UI)
|
// Refresh objects untuk trigger update parameter dari ONU
|
||||||
|
try {
|
||||||
|
await instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 15000 });
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// Tunggu sesaat agar task dieksekusi oleh ONU
|
||||||
if (ok) {
|
if (ok) {
|
||||||
instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 12000 }).catch(() => {});
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
instance.post(tasksUrl, { name: 'inform' }, { timeout: 12000 }).catch(() => {});
|
|
||||||
logger.info(`[updateSSID] Set SSID dikirim untuk ${tag}; refresh & inform berjalan di latar belakang`);
|
// Sinkronkan nama WiFi (SSID) baru ke database pelanggan
|
||||||
|
try {
|
||||||
|
const db = require('../config/database');
|
||||||
|
const pppoeUser = extractPppoeUser(device);
|
||||||
|
const tagCandidate = (device._tags && device._tags[0]) || null;
|
||||||
|
const snCandidate = device._deviceId?._SerialNumber || null;
|
||||||
|
const idCandidate = device._id || null;
|
||||||
|
|
||||||
|
const cust = db.prepare(`
|
||||||
|
SELECT id FROM customers
|
||||||
|
WHERE id = ?
|
||||||
|
OR (pppoe_username IS NOT NULL AND pppoe_username != '' AND (pppoe_username = ? OR pppoe_username = ?))
|
||||||
|
OR (genieacs_tag IS NOT NULL AND genieacs_tag != '' AND (genieacs_tag = ? OR genieacs_tag = ?))
|
||||||
|
OR (mac_address IS NOT NULL AND mac_address != '' AND (mac_address = ? OR mac_address = ? OR mac_address = ?))
|
||||||
|
OR phone = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(tag, tag, pppoeUser, tag, tagCandidate, tag, snCandidate, idCandidate, tag);
|
||||||
|
|
||||||
|
if (cust && cust.id) {
|
||||||
|
db.prepare('UPDATE customers SET wifi_ssid = ? WHERE id = ?').run(newSSID, cust.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`[updateSSID] Failed to sync customer wifi_ssid: ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catat audit trail jika berhasil
|
// Catat audit trail jika berhasil
|
||||||
@@ -970,71 +1046,78 @@ async function updatePassword(tag, newPassword, actor = null) {
|
|||||||
logger.info(`[updatePassword] Setting password for device ${deviceId}, tag ${tag}`);
|
logger.info(`[updatePassword] Setting password for device ${deviceId}, tag ${tag}`);
|
||||||
|
|
||||||
const parameterValues = [];
|
const parameterValues = [];
|
||||||
|
const igd = device.InternetGatewayDevice;
|
||||||
|
const dev181 = device.Device;
|
||||||
|
|
||||||
// Check supported paths in DB
|
if (igd && igd.LANDevice && igd.LANDevice['1'] && igd.LANDevice['1'].WLANConfiguration) {
|
||||||
const db = require('../config/database');
|
const wlan = igd.LANDevice['1'].WLANConfiguration;
|
||||||
const row = db.prepare('SELECT params FROM acs_devices WHERE id = ?').get(device._id);
|
const wlan1 = wlan['1'] || {};
|
||||||
const flatParams = row && row.params ? JSON.parse(row.params) : null;
|
|
||||||
|
|
||||||
if (flatParams) {
|
if (wlan1.KeyPassphrase !== undefined) {
|
||||||
// 2.4G password paths
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
|
||||||
const paths24G = [
|
|
||||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase',
|
|
||||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase',
|
|
||||||
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey',
|
|
||||||
'Device.WiFi.AccessPoint.1.Security.KeyPassphrase',
|
|
||||||
'Device.WiFi.AccessPoint.1.Security.PreSharedKey'
|
|
||||||
];
|
|
||||||
paths24G.forEach(p => {
|
|
||||||
if (flatParams[p] !== undefined) {
|
|
||||||
parameterValues.push([p, pw, 'xsd:string']);
|
|
||||||
}
|
}
|
||||||
});
|
if (wlan1.PreSharedKey && typeof wlan1.PreSharedKey === 'object') {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||||
// 5G password paths
|
|
||||||
const paths5G = [
|
|
||||||
'Device.WiFi.AccessPoint.2.Security.KeyPassphrase',
|
|
||||||
'Device.WiFi.AccessPoint.2.Security.PreSharedKey'
|
|
||||||
];
|
|
||||||
for (const idx of [5, 6, 7, 8]) {
|
|
||||||
paths5G.push(
|
|
||||||
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.KeyPassphrase`,
|
|
||||||
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.PreSharedKey.1.KeyPassphrase`,
|
|
||||||
`InternetGatewayDevice.LANDevice.1.WLANConfiguration.${idx}.PreSharedKey.1.PreSharedKey`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
paths5G.forEach(p => {
|
|
||||||
if (flatParams[p] !== undefined) {
|
|
||||||
parameterValues.push([p, pw, 'xsd:string']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if no parameters match or device not bootstrapped yet
|
|
||||||
if (parameterValues.length === 0) {
|
if (parameterValues.length === 0) {
|
||||||
parameterValues.push(
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
|
||||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string'],
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase', pw, 'xsd:string'],
|
}
|
||||||
['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string'],
|
|
||||||
['Device.WiFi.AccessPoint.1.Security.KeyPassphrase', pw, 'xsd:string']
|
// 5G if available - HANYA jika WLANConfiguration.5 ada di perangkat. JANGAN PERNAH mengubah WLANConfiguration.2!
|
||||||
|
const hasFiveG = Boolean(
|
||||||
|
wlan['5'] && (
|
||||||
|
wlan['5'].SSID !== undefined ||
|
||||||
|
wlan['5'].KeyPassphrase !== undefined ||
|
||||||
|
wlan['5'].PreSharedKey !== undefined ||
|
||||||
|
wlan['5']._object === true ||
|
||||||
|
wlan['5'].Enable !== undefined
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
if (hasFiveG) {
|
||||||
|
const wlan5 = wlan['5'] || {};
|
||||||
|
if (wlan5.KeyPassphrase !== undefined) {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase', pw, 'xsd:string']);
|
||||||
|
}
|
||||||
|
if (wlan5.PreSharedKey && typeof wlan5.PreSharedKey === 'object') {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||||
|
}
|
||||||
|
if (!wlan5.KeyPassphrase && (!wlan5.PreSharedKey || typeof wlan5.PreSharedKey !== 'object')) {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.KeyPassphrase', pw, 'xsd:string']);
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.5.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (dev181 && dev181.WiFi) {
|
||||||
|
parameterValues.push(['Device.WiFi.AccessPoint.1.Security.KeyPassphrase', pw, 'xsd:string']);
|
||||||
|
} else {
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.KeyPassphrase', pw, 'xsd:string']);
|
||||||
|
parameterValues.push(['InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.PreSharedKey', pw, 'xsd:string']);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ok = false;
|
let ok = false;
|
||||||
|
for (const pv of parameterValues) {
|
||||||
try {
|
try {
|
||||||
await instance.post(tasksUrl, {
|
await instance.post(tasksUrl, {
|
||||||
name: 'setParameterValues',
|
name: 'setParameterValues',
|
||||||
parameterValues: parameterValues
|
parameterValues: [pv]
|
||||||
}, { timeout: 20000 });
|
}, { timeout: 15000 });
|
||||||
ok = true;
|
ok = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`[updatePassword] Failed to set password: ${e.message}`);
|
logger.debug(`[updatePassword] Param ${pv[0]} failed: ${e.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh di latar belakang agar respons cepat
|
// Refresh object - only refresh InternetGatewayDevice path yang lebih universal
|
||||||
instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 12000 }).catch(() => {});
|
try {
|
||||||
|
await instance.post(tasksUrl, { name: 'refreshObject', objectName: 'InternetGatewayDevice.LANDevice.1.WLANConfiguration' }, { timeout: 15000 });
|
||||||
|
} catch (e) {}
|
||||||
// Skip Device.WiFi.AccessPoint refresh karena tidak semua ONU support (CIOT tidak support)
|
// Skip Device.WiFi.AccessPoint refresh karena tidak semua ONU support (CIOT tidak support)
|
||||||
|
|
||||||
|
// Tunggu sesaat agar task dieksekusi oleh ONU
|
||||||
|
if (ok) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
}
|
||||||
|
|
||||||
// Catat audit trail jika berhasil
|
// Catat audit trail jika berhasil
|
||||||
if (ok && actor) {
|
if (ok && actor) {
|
||||||
auditTrail.logAuditTrail({
|
auditTrail.logAuditTrail({
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ async function resolveDeviceId(force) {
|
|||||||
try { list = await listDevices(); } catch (e) { list = []; }
|
try { list = await listDevices(); } catch (e) { list = []; }
|
||||||
const has = function (id) { return list.some(function (d) { return String(d.id) === id; }); };
|
const has = function (id) { return list.some(function (d) { return String(d.id) === id; }); };
|
||||||
let chosen;
|
let chosen;
|
||||||
|
const connected = list.find(function (d) {
|
||||||
|
return d && (d.state === 'connected' || d.is_connected || d.is_logged_in || d.connected);
|
||||||
|
});
|
||||||
if (configured && has(configured)) chosen = configured; // device id diset & ada -> pakai itu
|
if (configured && has(configured)) chosen = configured; // device id diset & ada -> pakai itu
|
||||||
|
else if (connected) chosen = String(connected.id); // utamakan device yang benar-benar terhubung
|
||||||
else if (list.length === 1) chosen = String(list[0].id); // cuma 1 device -> otomatis
|
else if (list.length === 1) chosen = String(list[0].id); // cuma 1 device -> otomatis
|
||||||
else if (list.length > 1) chosen = configured || String(list[0].id); // >1 device -> pakai yg diset / pertama
|
else if (list.length > 1) chosen = configured || String(list[0].id); // >1 device -> pakai yg diset / pertama
|
||||||
else chosen = configured; // GOWA tak beri list -> serahkan ke GOWA
|
else chosen = configured; // GOWA tak beri list -> serahkan ke GOWA
|
||||||
@@ -131,6 +135,48 @@ async function sendGowaDocument(toPhone, documentBuffer, filename = 'file.pdf',
|
|||||||
});
|
});
|
||||||
return { success: true, gateway: 'gowa', raw: json };
|
return { success: true, gateway: 'gowa', raw: json };
|
||||||
}
|
}
|
||||||
|
async function testReachability(opts = {}) {
|
||||||
|
// Nilai form boleh dipakai tanpa menyimpan pengaturan lebih dulu.
|
||||||
|
const base = String(opts.baseUrl || baseUrl()).trim().replace(/\/+$/, '') || baseUrl();
|
||||||
|
const headers = {};
|
||||||
|
if (opts.username && opts.password) {
|
||||||
|
headers.Authorization = 'Basic ' + Buffer.from(opts.username + ':' + opts.password).toString('base64');
|
||||||
|
} else {
|
||||||
|
Object.assign(headers, commonHeaders());
|
||||||
|
}
|
||||||
|
const out = { baseUrl: base, reachable: false, healthStatus: 0, authOk: false, devices: 0, message: '' };
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + '/health');
|
||||||
|
out.healthStatus = res.status;
|
||||||
|
out.reachable = res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
out.message = 'Tidak terjangkau: ' + e.message;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (!out.reachable) {
|
||||||
|
out.message = 'GOWA menjawab, tetapi /health mengembalikan HTTP ' + out.healthStatus;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + '/devices', { headers: headers });
|
||||||
|
out.authStatus = res.status;
|
||||||
|
const json = await res.json().catch(function () { return null; });
|
||||||
|
const list = (json && Array.isArray(json.results)) ? json.results : [];
|
||||||
|
if (res.ok) {
|
||||||
|
out.authOk = true;
|
||||||
|
out.devices = list.length;
|
||||||
|
out.message = 'GOWA terjangkau' + (list.length ? ' — ' + list.length + ' device terdaftar' : ' — belum ada device, scan QR untuk menautkan');
|
||||||
|
} else if (res.status === 401) {
|
||||||
|
out.message = 'GOWA terjangkau, tetapi username/password Basic Auth salah atau kosong';
|
||||||
|
} else {
|
||||||
|
out.message = 'GOWA terjangkau, tetapi /devices membalas HTTP ' + res.status;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.message = 'GOWA terjangkau, tetapi gagal cek kredensial: ' + e.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function testGowaConnection(phone, message) {
|
async function testGowaConnection(phone, message) {
|
||||||
return await sendGowaMessage(phone, message || 'Tes koneksi GOWA berhasil ✅');
|
return await sendGowaMessage(phone, message || 'Tes koneksi GOWA berhasil ✅');
|
||||||
}
|
}
|
||||||
@@ -168,21 +214,61 @@ async function ensureDevice() {
|
|||||||
async function getLoginQr(deviceId) {
|
async function getLoginQr(deviceId) {
|
||||||
const dev = deviceId || deviceHeader();
|
const dev = deviceId || deviceHeader();
|
||||||
const url = baseUrl() + '/app/login' + (dev ? ('?device_id=' + encodeURIComponent(dev)) : '');
|
const url = baseUrl() + '/app/login' + (dev ? ('?device_id=' + encodeURIComponent(dev)) : '');
|
||||||
|
try {
|
||||||
const res = await fetch(url, { headers: commonHeaders() });
|
const res = await fetch(url, { headers: commonHeaders() });
|
||||||
const json = await res.json().catch(function () { return null; });
|
const text = await res.text();
|
||||||
|
let json = null;
|
||||||
|
try { json = JSON.parse(text); } catch (e) {}
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = String(
|
||||||
|
(json && (json.message || json.error || json.code)) || text || ''
|
||||||
|
).replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: res.status,
|
||||||
|
code: (json && json.code) || null,
|
||||||
|
message: detail || ('HTTP ' + res.status)
|
||||||
|
};
|
||||||
|
}
|
||||||
const r = (json && json.results) || {};
|
const r = (json && json.results) || {};
|
||||||
const link = r.qr_link || r.image_path || null;
|
const link = r.qr_link || r.image_path || null;
|
||||||
if (link) _lastQr = { link: link, at: Date.now() };
|
if (link) _lastQr = { link: link, at: Date.now() };
|
||||||
return { ok: !!link, qr_link: link, duration: r.qr_duration || null, device_id: r.device_id || dev || null, code: (json && json.code) || null };
|
return { ok: !!link, qr_link: link, duration: r.qr_duration || null, device_id: r.device_id || dev || null, code: (json && json.code) || null };
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, status: 0, code: null, message: e.message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function getLastQrImage() {
|
// GOWA bisa mengembalikan qr_link absolut ke host internal (127.0.0.1 / nama
|
||||||
if (!_lastQr.link) return null;
|
// container). Ambil path-nya saja lalu tempel ke base URL yang dikonfigurasi,
|
||||||
|
// supaya gambar tetap bisa diambil saat GOWA diakses lewat domain/reverse proxy.
|
||||||
|
function resolveQrLink(link) {
|
||||||
|
const l = String(link || '').trim();
|
||||||
|
if (!l) return '';
|
||||||
|
if (/^data:/i.test(l)) return l;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(_lastQr.link, { headers: commonHeaders() });
|
const u = new URL(l, baseUrl() + '/');
|
||||||
if (!res.ok) return null;
|
const base = new URL(baseUrl());
|
||||||
const buf = Buffer.from(await res.arrayBuffer());
|
return base.origin + u.pathname + u.search;
|
||||||
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
|
} catch (e) {
|
||||||
} catch (e) { return null; }
|
return baseUrl() + (l.charAt(0) === '/' ? l : '/' + l);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };
|
async function getLastQrImage() {
|
||||||
|
if (!_lastQr.link) return null;
|
||||||
|
const url = resolveQrLink(_lastQr.link);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: commonHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn(`[GOWA] Gagal ambil gambar QR: HTTP ${res.status} (${url})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
return { buffer: buf, contentType: res.headers.get('content-type') || 'image/png' };
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[GOWA] Gagal ambil gambar QR: ${e.message} (${url})`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendGowaMessage, sendGowaImage, sendGowaDocument, testGowaConnection, testReachability, gowaStatus, listDevices, ensureDevice, getLoginQr, getLastQrImage, normalizePhoneJid, baseUrl, resolveDeviceId, invalidateDeviceCache };
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ const db = require('../config/database');
|
|||||||
const qrisUtil = require('../utils/qrisUtil');
|
const qrisUtil = require('../utils/qrisUtil');
|
||||||
const { getSettings } = require('../config/settingsManager');
|
const { getSettings } = require('../config/settingsManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cek apakah nominal unik sudah dipakai order lain yang masih menunggu bayar.
|
||||||
|
* WAJIB mencakup invoice, voucher, dan donasi: webhook mencocokkan ketiganya
|
||||||
|
* sekaligus, jadi nominal yang dipakai dua order berbeda akan dianggap ambigu
|
||||||
|
* dan pembayarannya tidak diproses.
|
||||||
|
*/
|
||||||
|
function isQrisAmountTaken(amount, opts = {}) {
|
||||||
|
const amt = Number(amount || 0);
|
||||||
|
if (!Number.isFinite(amt) || amt <= 0) return false;
|
||||||
|
const exInv = Number(opts.excludeInvoiceId || 0);
|
||||||
|
const exVoucher = Number(opts.excludeVoucherOrderId || 0);
|
||||||
|
const exDonation = Number(opts.excludeDonationOrderId || 0);
|
||||||
|
const exCustomQr = Number(opts.excludeCustomQrId || 0);
|
||||||
|
try {
|
||||||
|
const inv = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('unpaid', amt, exInv);
|
||||||
|
if (inv && inv.id) return true;
|
||||||
|
const voc = db.prepare('SELECT id FROM public_voucher_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exVoucher);
|
||||||
|
if (voc && voc.id) return true;
|
||||||
|
const don = db.prepare('SELECT id FROM public_donation_orders WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1').get('pending', amt, exDonation);
|
||||||
|
if (don && don.id) return true;
|
||||||
|
const cq = db.prepare("SELECT id FROM custom_qr_payments WHERE status IN ('pending','expired') AND amount_unique=? AND id!=? LIMIT 1").get(amt, exCustomQr);
|
||||||
|
if (cq && cq.id) return true;
|
||||||
|
} catch (e) { /* tabel opsional belum ada */ }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureInvoiceQris(inv) {
|
function ensureInvoiceQris(inv) {
|
||||||
const settings = getSettings() || {};
|
const settings = getSettings() || {};
|
||||||
const invId = Number((inv && inv.id) || 0);
|
const invId = Number((inv && inv.id) || 0);
|
||||||
@@ -17,11 +43,10 @@ function ensureInvoiceQris(inv) {
|
|||||||
let amountUnique = Number(inv.qris_amount_unique || 0);
|
let amountUnique = Number(inv.qris_amount_unique || 0);
|
||||||
|
|
||||||
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
|
if (!(amountUnique > 0 && code > 0 && (amountUnique - code) === base)) {
|
||||||
const exists = db.prepare('SELECT id FROM invoices WHERE status=? AND qris_amount_unique=? AND id!=? LIMIT 1');
|
|
||||||
let chosen = 0, chosenAmt = 0;
|
let chosen = 0, chosenAmt = 0;
|
||||||
for (let c = 1; c <= 999; c++) {
|
for (let c = 1; c <= 999; c++) {
|
||||||
const a = base + c;
|
const a = base + c;
|
||||||
if (!exists.get('unpaid', a, invId)) { chosen = c; chosenAmt = a; break; }
|
if (!isQrisAmountTaken(a, { excludeInvoiceId: invId })) { chosen = c; chosenAmt = a; break; }
|
||||||
}
|
}
|
||||||
if (chosen) {
|
if (chosen) {
|
||||||
code = chosen;
|
code = chosen;
|
||||||
@@ -43,4 +68,4 @@ function ensureInvoiceQris(inv) {
|
|||||||
return { uniqueCode: code, amount: finalAmount, payload };
|
return { uniqueCode: code, amount: finalAmount, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ensureInvoiceQris };
|
module.exports = { ensureInvoiceQris, isQrisAmountTaken };
|
||||||
|
|||||||
@@ -1632,6 +1632,112 @@ async function generateIsolirPortalScript() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script isolir sederhana untuk billing yang berjalan di CLOUD.
|
||||||
|
* Asumsi: redirector billing (port isolir_redirect_port) terbuka dari publik.
|
||||||
|
*/
|
||||||
|
async function generateIsolirCloudScript(opts = {}) {
|
||||||
|
const settings = getSettingsWithCache() || {};
|
||||||
|
const rawHost = String(opts.host || settings.public_base_url || settings.app_url || '').trim();
|
||||||
|
if (!rawHost) throw new Error('Isi domain/IP publik billing terlebih dahulu.');
|
||||||
|
|
||||||
|
let url = null;
|
||||||
|
try {
|
||||||
|
url = new URL(/^https?:\/\//i.test(rawHost) ? rawHost : ('https://' + rawHost));
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Domain/IP billing tidak valid.');
|
||||||
|
}
|
||||||
|
const hostname = url.hostname;
|
||||||
|
const baseUrl = url.origin;
|
||||||
|
let billingIps = [];
|
||||||
|
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
|
||||||
|
billingIps = [hostname];
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const list = await dns.promises.resolve4(hostname);
|
||||||
|
billingIps = (list || []).filter(Boolean);
|
||||||
|
} catch (e) {}
|
||||||
|
if (!billingIps.length) {
|
||||||
|
try { billingIps = [await resolveIpv4(hostname)]; } catch (e) { billingIps = []; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!billingIps.length) throw new Error('IP publik billing tidak bisa di-resolve. Masukkan IP origin langsung.');
|
||||||
|
const billingIp = billingIps[0];
|
||||||
|
const cfRanges = ['104.16.', '104.17.', '104.18.', '104.19.', '104.20.', '104.21.', '104.22.', '104.23.', '104.24.', '104.25.', '104.26.', '104.27.', '172.64.', '172.65.', '172.66.', '172.67.', '162.158.', '173.245.', '103.21.', '103.22.', '103.31.', '141.101.', '108.162.', '190.93.', '188.114.', '197.234.', '198.41.', '131.0.72.'];
|
||||||
|
const isCdn = billingIps.some(ip => cfRanges.some(p => ip.startsWith(p)));
|
||||||
|
const redirectPort = Math.max(1, Math.min(65535, parseInt(opts.port, 10) || parseInt(settings.isolir_redirect_port, 10) || 8099));
|
||||||
|
const profile = String(opts.profile || 'isolir').trim() || 'isolir';
|
||||||
|
const isolirUrl = String(settings.isolir_portal_url || (baseUrl + '/isolated')).trim();
|
||||||
|
|
||||||
|
const onUp = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]; /ip firewall address-list add list=LIST_ISOLIR address=$\\"remote-address\\" comment=$user timeout=23h`;
|
||||||
|
const onDown = `/ip firewall address-list remove [find list=LIST_ISOLIR address=$\\"remote-address\\"]`;
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
'# ============================================================',
|
||||||
|
'# ISOLIR PAGE — BILLING CLOUD (script sederhana)',
|
||||||
|
'# Tempel di Terminal RouterOS. Ubah variabel di bawah bila perlu.',
|
||||||
|
'# ============================================================',
|
||||||
|
...(isCdn ? [
|
||||||
|
'# PERINGATAN: ' + hostname + ' memakai Cloudflare/CDN.',
|
||||||
|
'# Port ' + redirectPort + ' biasanya TIDAK ikut diproxy Cloudflare.',
|
||||||
|
'# Untuk NAT redirector, gunakan IP ORIGIN (DNS-only) atau pastikan',
|
||||||
|
'# port ' + redirectPort + ' terbuka langsung di server origin.',
|
||||||
|
'# ============================================================',
|
||||||
|
''
|
||||||
|
] : []),
|
||||||
|
':local billingIp "' + billingIp + '"',
|
||||||
|
':local isolirPort ' + redirectPort,
|
||||||
|
':local isolirProfile "' + profile + '"',
|
||||||
|
'',
|
||||||
|
'# --- Bersihkan rule lama buatan script ini ---',
|
||||||
|
'/ip firewall nat remove [find comment~"ISOLIR_CLOUD_"]',
|
||||||
|
'/ip firewall filter remove [find comment~"ISOLIR_CLOUD_"]',
|
||||||
|
'/ip firewall address-list remove [find list=BILLING comment="BILLING_ISOLIR"]',
|
||||||
|
'',
|
||||||
|
'# --- Izinkan billing (semua IP hasil resolve) ---',
|
||||||
|
...billingIps.map(function (ip) { return '/ip firewall address-list add list=BILLING address=' + ip + ' comment="BILLING_ISOLIR"'; }),
|
||||||
|
'',
|
||||||
|
'# --- Hook profil PPPoE isolir ---',
|
||||||
|
'/ppp profile set [find name=$isolirProfile] on-up="' + onUp + '" on-down="' + onDown + '"',
|
||||||
|
'',
|
||||||
|
'# --- DNS isolir diarahkan ke router ---',
|
||||||
|
'/ip firewall nat add chain=dstnat protocol=udp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_UDP"',
|
||||||
|
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=53 src-address-list=LIST_ISOLIR action=redirect to-ports=53 comment="ISOLIR_CLOUD_DNS_TCP"',
|
||||||
|
'',
|
||||||
|
'# --- HTTP pelanggan isolir -> redirector cloud ---',
|
||||||
|
'/ip firewall nat add chain=dstnat protocol=tcp dst-port=80 src-address-list=LIST_ISOLIR dst-address-list=!BILLING action=dst-nat to-addresses=$billingIp to-ports=$isolirPort comment="ISOLIR_CLOUD_HTTP"',
|
||||||
|
'',
|
||||||
|
'# --- Filter ---',
|
||||||
|
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=udp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS"',
|
||||||
|
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=53 action=accept comment="ISOLIR_CLOUD_DNS_TCP"',
|
||||||
|
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR dst-address-list=BILLING action=accept comment="ISOLIR_CLOUD_ALLOW_BILLING"',
|
||||||
|
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR protocol=tcp dst-port=443 action=reject comment="ISOLIR_CLOUD_REJECT_HTTPS"',
|
||||||
|
'/ip firewall filter add chain=forward src-address-list=LIST_ISOLIR action=drop comment="ISOLIR_CLOUD_DROP"',
|
||||||
|
'',
|
||||||
|
'# ============================================================',
|
||||||
|
'# CATATAN BILLING CLOUD',
|
||||||
|
'# 1. Redirector billing harus bisa diakses: http://' + billingIp + ':' + redirectPort,
|
||||||
|
'# (buka port ' + redirectPort + ' di firewall/security group cloud).',
|
||||||
|
'# 2. Di billing set ISOLIR_REDIRECT_URL=' + isolirUrl,
|
||||||
|
'# atau setting isolir_portal_url.',
|
||||||
|
'# 3. Halaman isolir: ' + isolirUrl,
|
||||||
|
'# 4. Jika pakai Cloudflare/CDN, pastikan IP edge yang dipakai ada di',
|
||||||
|
'# address-list BILLING, atau gunakan IP origin langsung.',
|
||||||
|
'# ============================================================',
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
script: lines.join('\n'),
|
||||||
|
billingHost: hostname,
|
||||||
|
billingIp,
|
||||||
|
billingIps,
|
||||||
|
isCdn,
|
||||||
|
redirectPort,
|
||||||
|
profile,
|
||||||
|
isolirUrl,
|
||||||
|
warning: isCdn ? ('Domain memakai Cloudflare/CDN. NAT redirector ke ' + billingIp + ':' + redirectPort + ' biasanya gagal — gunakan IP origin (DNS-only) atau buka port redirector di origin.') : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ISOLIR_ADDR_LIST = 'LIST_ISOLIR';
|
const ISOLIR_ADDR_LIST = 'LIST_ISOLIR';
|
||||||
|
|
||||||
/** Nama profil PPPoE isolir yang dipakai pelanggan di router ini (distinct dari DB). */
|
/** Nama profil PPPoE isolir yang dipakai pelanggan di router ini (distinct dari DB). */
|
||||||
@@ -2220,6 +2326,7 @@ module.exports = {
|
|||||||
setupIsolirFirewall,
|
setupIsolirFirewall,
|
||||||
ensurePppProfileIsolirAddressListHook,
|
ensurePppProfileIsolirAddressListHook,
|
||||||
generateIsolirPortalScript,
|
generateIsolirPortalScript,
|
||||||
|
generateIsolirCloudScript,
|
||||||
manageStaticIp,
|
manageStaticIp,
|
||||||
removeStaticIp
|
removeStaticIp
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ class SecurityWatchdogService {
|
|||||||
processedSet.add(logId);
|
processedSet.add(logId);
|
||||||
|
|
||||||
const msg = String(l.message || '');
|
const msg = String(l.message || '');
|
||||||
if (msg.toLowerCase().includes('login failure') || msg.toLowerCase().includes('failure')) {
|
const low = msg.toLowerCase();
|
||||||
|
if (low.includes('login failure') || low.includes('failure') || low.includes('login failed')) {
|
||||||
failures.push(l);
|
failures.push(l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,19 +201,31 @@ class SecurityWatchdogService {
|
|||||||
const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) ||
|
const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) ||
|
||||||
msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i);
|
msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i);
|
||||||
|
|
||||||
if (match) {
|
// Format hotspot: "10.5.50.2 (26:BD:26:2A:C3:47): login failed: invalid username or password"
|
||||||
const targetUser = match[1] || 'unknown';
|
const hotspotMatch = msg.match(/(?:->:\s*)?([0-9a-fA-F.:]+)\s*\(([0-9a-fA-F:]+)\):\s*login failed/i);
|
||||||
const attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
|
|
||||||
const service = match[3] || 'router-service';
|
|
||||||
|
|
||||||
if (!attackerIp || attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue;
|
let targetUser, attackerIp, service, mac;
|
||||||
|
if (hotspotMatch) {
|
||||||
|
attackerIp = String(hotspotMatch[1] || '').replace(/^::ffff:/, '').trim();
|
||||||
|
mac = String(hotspotMatch[2] || '').toUpperCase();
|
||||||
|
targetUser = 'voucher-hotspot';
|
||||||
|
service = 'hotspot';
|
||||||
|
} else if (match) {
|
||||||
|
targetUser = match[1] || 'unknown';
|
||||||
|
attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
|
||||||
|
service = match[3] || 'router-service';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attackerIp) {
|
||||||
|
if (attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue;
|
||||||
|
|
||||||
if (!failuresByIp.has(attackerIp)) {
|
if (!failuresByIp.has(attackerIp)) {
|
||||||
failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service });
|
failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service, macs: new Set(mac ? [mac] : []) });
|
||||||
} else {
|
} else {
|
||||||
const item = failuresByIp.get(attackerIp);
|
const item = failuresByIp.get(attackerIp);
|
||||||
item.count++;
|
item.count++;
|
||||||
item.targets.add(targetUser);
|
item.targets.add(targetUser);
|
||||||
|
if (mac) item.macs.add(mac);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,6 +270,16 @@ class SecurityWatchdogService {
|
|||||||
|
|
||||||
await this._notifyAdmin(waMsg);
|
await this._notifyAdmin(waMsg);
|
||||||
|
|
||||||
|
// 2b. AUTO-BLOCK (opsional, default nonaktif)
|
||||||
|
if (String(getSetting('security_auto_block_enabled', 'false')) === 'true' && data.count >= (parseInt(getSetting('security_mikrotik_threshold', 5), 10) || 5)) {
|
||||||
|
try {
|
||||||
|
const actions = await this._blockMikrotikIp(conn, attackerIp, r.name, data);
|
||||||
|
if (actions.length) logger.warn('[SecurityWatchdog] Auto-block ' + attackerIp + ': ' + actions.join('; '));
|
||||||
|
} catch (blockErr) {
|
||||||
|
logger.error('[SecurityWatchdog] Auto-block gagal: ' + blockErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. CATAT KE AUDIT TRAIL
|
// 3. CATAT KE AUDIT TRAIL
|
||||||
this._recordAudit({
|
this._recordAudit({
|
||||||
action: 'ROUTER_BRUTE_FORCE',
|
action: 'ROUTER_BRUTE_FORCE',
|
||||||
@@ -284,6 +307,59 @@ class SecurityWatchdogService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blokir IP penyerang di MikroTik (firewall address-list + drop rule).
|
||||||
|
* Hanya dipanggil bila security_auto_block_enabled=true.
|
||||||
|
*/
|
||||||
|
async _blockMikrotikIp(conn, ip, routerName, data) {
|
||||||
|
const actions = [];
|
||||||
|
const safeIp = String(ip || '').trim();
|
||||||
|
if (!safeIp || safeIp === '127.0.0.1' || safeIp === 'localhost') return actions;
|
||||||
|
// Hotspot brute force: blokir juga MAC di IP-Binding
|
||||||
|
if (data.macs && data.macs.size) {
|
||||||
|
const bindMenu = conn.client.menu('/ip/hotspot/ip-binding');
|
||||||
|
const bindings = await bindMenu.get();
|
||||||
|
for (const mac of data.macs) {
|
||||||
|
const found = bindings.find(b => String(b['mac-address'] || b.macAddress || '').toUpperCase() === mac);
|
||||||
|
if (!found) {
|
||||||
|
await bindMenu.add({ macAddress: mac, type: 'blocked', comment: 'AI_BLOCKED: hotspot brute force (' + data.count + 'x)' }).catch(function () {});
|
||||||
|
actions.push('MAC ' + mac + ' diblokir di Hotspot IP-Binding');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listMenu = conn.client.menu('/ip/firewall/address-list');
|
||||||
|
const rows = await listMenu.get();
|
||||||
|
const exists = rows.find(a => a.address === safeIp && (a.list === 'BLACKLIST_BRUTEFORCE' || a.list === 'BLACKLIST_ANOMALY'));
|
||||||
|
if (!exists) {
|
||||||
|
await listMenu.add({
|
||||||
|
list: 'BLACKLIST_BRUTEFORCE',
|
||||||
|
address: safeIp,
|
||||||
|
timeout: '1d',
|
||||||
|
comment: 'AI_BLOCKED: ' + String(data.service || 'router').toUpperCase() + ' (' + data.count + 'x gagal)'
|
||||||
|
});
|
||||||
|
actions.push('IP ' + safeIp + ' masuk BLACKLIST_BRUTEFORCE (1 hari)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pastikan rule drop pada chain input & forward
|
||||||
|
const filterMenu = conn.client.menu('/ip/firewall/filter');
|
||||||
|
const filters = await filterMenu.get();
|
||||||
|
const hasDrop = (chain) => filters.some(f => f.chain === chain && f.action === 'drop' &&
|
||||||
|
(f['src-address-list'] === 'BLACKLIST_BRUTEFORCE' || f.srcAddressList === 'BLACKLIST_BRUTEFORCE'));
|
||||||
|
for (const chain of ['input', 'forward']) {
|
||||||
|
if (!hasDrop(chain)) {
|
||||||
|
await filterMenu.add({
|
||||||
|
chain,
|
||||||
|
action: 'drop',
|
||||||
|
srcAddressList: 'BLACKLIST_BRUTEFORCE',
|
||||||
|
comment: 'AI_AUTO_DROP_BLACKLIST_BRUTEFORCE'
|
||||||
|
}).catch(function () {});
|
||||||
|
actions.push('rule drop ' + chain + ' ditambahkan');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ringkasan status keamanan saat ini
|
* Ringkasan status keamanan saat ini
|
||||||
*/
|
*/
|
||||||
|
|||||||
136
services/serviceNoticeNotifyService.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* serviceNoticeNotifyService.js
|
||||||
|
* Notifikasi WhatsApp ke pelanggan untuk:
|
||||||
|
* - tiket gangguan yang baru dibuat
|
||||||
|
* - pengumuman gangguan/perbaikan dari halaman Status Layanan
|
||||||
|
* - pemberitahuan layanan pulih (mode kembali NORMAL)
|
||||||
|
*
|
||||||
|
* Pengiriman broadcast berjalan di latar belakang dengan jeda acak
|
||||||
|
* mengikuti setting whatsapp_broadcast_delay (anti-banned).
|
||||||
|
*/
|
||||||
|
const customerSvc = require('./customerService');
|
||||||
|
const waNotify = require('./waNotifyService');
|
||||||
|
const { getSetting } = require('../config/settingsManager');
|
||||||
|
const { logger } = require('../config/logger');
|
||||||
|
|
||||||
|
let _running = false;
|
||||||
|
|
||||||
|
function digits(p) {
|
||||||
|
return String(p || '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function companyName() {
|
||||||
|
return getSetting('company_header', 'OPEN-ISP') || 'OPEN-ISP';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function delayMs() {
|
||||||
|
const sec = Number(getSetting('whatsapp_broadcast_delay', 5)) || 5;
|
||||||
|
return Math.max(2000, sec * 1000) + Math.floor(Math.random() * 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketMessage(customer, ticketId, subject) {
|
||||||
|
return `✅ *Laporan Diterima*\n\n` +
|
||||||
|
`Halo *${customer.name || 'Pelanggan'}*, laporan gangguan Anda sudah kami terima.\n\n` +
|
||||||
|
`🎫 *No. Tiket:* #TCK-${ticketId}\n` +
|
||||||
|
`📝 *Subjek:* ${subject}\n\n` +
|
||||||
|
`Tim teknisi akan segera menindaklanjuti. Kami akan mengabari lagi setelah laporan selesai.\n\n` +
|
||||||
|
`_${companyName()}_`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function noticeMessage(notice, customer) {
|
||||||
|
let text = `⚠️ *${notice.label || 'Informasi Layanan'}*\n\n` +
|
||||||
|
`Halo *${customer.name || 'Pelanggan'}*,\n${notice.text || ''}\n`;
|
||||||
|
if (notice.startText) text += `\n🕒 *Mulai:* ${notice.startText}`;
|
||||||
|
if (notice.estimateText) text += `\n⏳ *Estimasi Selesai:* ${notice.estimateText}`;
|
||||||
|
text += `\n\nMohon maaf atas ketidaknyamanannya.\n\n_${companyName()}_`;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoveryMessage(customer) {
|
||||||
|
return `✅ *Layanan Kembali Normal*\n\n` +
|
||||||
|
`Halo *${customer.name || 'Pelanggan'}*, gangguan/perbaikan layanan sudah selesai dan internet Anda dapat digunakan kembali seperti biasa.\n\n` +
|
||||||
|
`Terima kasih atas kesabaran Anda.\n\n_${companyName()}_`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyTicketCreated(customer, ticketId, subject) {
|
||||||
|
try {
|
||||||
|
if (!getSetting('whatsapp_enabled', false)) return false;
|
||||||
|
const phone = digits(customer && customer.phone);
|
||||||
|
if (phone.length < 8) return false;
|
||||||
|
return await waNotify.sendText(phone, ticketMessage(customer, ticketId, subject || 'Gangguan Layanan'));
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`[NoticeWA] notifyTicketCreated: ${e.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTargets({ target, area, routerId }) {
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
list = customerSvc.getAllCustomers(
|
||||||
|
'',
|
||||||
|
target === 'router' && routerId ? Number(routerId) : null,
|
||||||
|
'active',
|
||||||
|
target === 'area' ? String(area || '') : '',
|
||||||
|
{}
|
||||||
|
) || [];
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[NoticeWA] gagal memilih target: ${e.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const c of list) {
|
||||||
|
const phone = digits(c.phone);
|
||||||
|
if (phone.length >= 8 && !seen.has(phone)) {
|
||||||
|
seen.add(phone);
|
||||||
|
out.push({ ...c, phone });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kirim pengumuman ke pelanggan aktif (latar belakang).
|
||||||
|
* @returns {{ok:boolean,total?:number,reason?:string}}
|
||||||
|
*/
|
||||||
|
async function broadcastNotice({ mode, target, area, routerId, notice }) {
|
||||||
|
if (_running) return { ok: false, reason: 'busy' };
|
||||||
|
if (!getSetting('whatsapp_enabled', false)) return { ok: false, reason: 'wa_disabled' };
|
||||||
|
|
||||||
|
const recipients = selectTargets({ target: target || 'all', area, routerId });
|
||||||
|
if (!recipients.length) return { ok: false, reason: 'empty' };
|
||||||
|
|
||||||
|
const isRecovery = mode === 'NORMAL';
|
||||||
|
_running = true;
|
||||||
|
logger.info(`[NoticeWA] mulai mode=${mode} target=${target || 'all'} total=${recipients.length}`);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let sent = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const c of recipients) {
|
||||||
|
await sleep(delayMs());
|
||||||
|
try {
|
||||||
|
const ok = await waNotify.sendText(c.phone, isRecovery ? recoveryMessage(c) : noticeMessage(notice, c));
|
||||||
|
if (ok) sent++; else failed++;
|
||||||
|
} catch (e) {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_running = false;
|
||||||
|
logger.info(`[NoticeWA] selesai mode=${mode} terkirim=${sent} gagal=${failed}`);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { ok: true, total: recipients.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning() {
|
||||||
|
return _running;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { notifyTicketCreated, broadcastNotice, isRunning };
|
||||||
@@ -3,7 +3,7 @@ const { getSetting, getSettings, saveSettings } = require('../config/settingsMan
|
|||||||
const { getAppSetting, saveAppSetting } = require('../config/database');
|
const { getAppSetting, saveAppSetting } = require('../config/database');
|
||||||
|
|
||||||
const FEATURE_PASSWORD_HASH = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // sha256 kode aktivasi — plaintext hanya di settings.json (activation_code), tidak di repo
|
const FEATURE_PASSWORD_HASH = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // sha256 kode aktivasi — plaintext hanya di settings.json (activation_code), tidak di repo
|
||||||
const FEATURE_CONTACT_PHONE = '081947215703';
|
const FEATURE_CONTACT_PHONE = '';
|
||||||
|
|
||||||
function getFeaturePasswordHash() {
|
function getFeaturePasswordHash() {
|
||||||
return getSetting('feature_password_hash', FEATURE_PASSWORD_HASH);
|
return getSetting('feature_password_hash', FEATURE_PASSWORD_HASH);
|
||||||
@@ -32,6 +32,7 @@ const MENU_DEFINITIONS = [
|
|||||||
{ key: 'map', section: 'main', href: '/admin/map', icon: 'bi bi-map', labelKey: 'admin.nav.network_map', labelDefault: 'Peta Jaringan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 4, activePages: ['map'] },
|
{ key: 'map', section: 'main', href: '/admin/map', icon: 'bi bi-map', labelKey: 'admin.nav.network_map', labelDefault: 'Peta Jaringan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 4, activePages: ['map'] },
|
||||||
{ key: 'whatsapp', section: 'main', href: '/admin/whatsapp', icon: 'bi bi-whatsapp', labelKey: 'admin.nav.whatsapp', labelDefault: 'WhatsApp', roles: ['admin', 'cashier'], activePages: ['whatsapp','broadcast','whatsapp_live_chat','whatsapp_templates'] },
|
{ key: 'whatsapp', section: 'main', href: '/admin/whatsapp', icon: 'bi bi-whatsapp', labelKey: 'admin.nav.whatsapp', labelDefault: 'WhatsApp', roles: ['admin', 'cashier'], activePages: ['whatsapp','broadcast','whatsapp_live_chat','whatsapp_templates'] },
|
||||||
{ key: 'automation', section: 'main', href: '/admin/automation', icon: 'bi bi-robot', labelKey: 'admin.nav.automation', labelDefault: 'Automation', roles: ['admin'], activePages: ['automation'] },
|
{ key: 'automation', section: 'main', href: '/admin/automation', icon: 'bi bi-robot', labelKey: 'admin.nav.automation', labelDefault: 'Automation', roles: ['admin'], activePages: ['automation'] },
|
||||||
|
{ key: 'custom_qr', section: 'main', href: '/admin/custom-qr', icon: 'bi bi-qr-code', labelKey: 'admin.nav.custom_qr', labelDefault: 'QR-Custom Payment', roles: ['admin'], activePages: ['custom_qr'] },
|
||||||
|
|
||||||
{ key: 'grp_customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers', labelDefault: 'Pelanggan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 2, activePages: ['customers', 'onu_stickers'], children: [
|
{ key: 'grp_customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers', labelDefault: 'Pelanggan', roles: ['admin', 'cashier'], bottomNav: true, bottomNavOrder: 2, activePages: ['customers', 'onu_stickers'], children: [
|
||||||
{ key: 'customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers_data', labelDefault: 'Data Pelanggan', roles: ['admin', 'cashier'], activePages: ['customers'] },
|
{ key: 'customers', section: 'billing', href: '/admin/customers', icon: 'bi bi-people', labelKey: 'admin.nav.customers_data', labelDefault: 'Data Pelanggan', roles: ['admin', 'cashier'], activePages: ['customers'] },
|
||||||
@@ -103,6 +104,7 @@ const DEFAULT_MENU_STATES = {
|
|||||||
olts: STATE_HIDDEN,
|
olts: STATE_HIDDEN,
|
||||||
whatsapp: STATE_VISIBLE,
|
whatsapp: STATE_VISIBLE,
|
||||||
automation: STATE_VISIBLE,
|
automation: STATE_VISIBLE,
|
||||||
|
custom_qr: STATE_VISIBLE,
|
||||||
broadcast: STATE_VISIBLE,
|
broadcast: STATE_VISIBLE,
|
||||||
whatsapp_live_chat: STATE_VISIBLE,
|
whatsapp_live_chat: STATE_VISIBLE,
|
||||||
promo_slides: STATE_VISIBLE,
|
promo_slides: STATE_VISIBLE,
|
||||||
@@ -115,16 +117,16 @@ const DEFAULT_MENU_STATES = {
|
|||||||
cashiers_reports: STATE_VISIBLE,
|
cashiers_reports: STATE_VISIBLE,
|
||||||
collector_payments: STATE_VISIBLE,
|
collector_payments: STATE_VISIBLE,
|
||||||
tickets: STATE_VISIBLE,
|
tickets: STATE_VISIBLE,
|
||||||
inventory: STATE_LOCKED,
|
inventory: STATE_VISIBLE,
|
||||||
attendance: STATE_LOCKED,
|
attendance: STATE_VISIBLE,
|
||||||
payroll: STATE_LOCKED,
|
payroll: STATE_VISIBLE,
|
||||||
cash_in: STATE_VISIBLE,
|
cash_in: STATE_VISIBLE,
|
||||||
expenses: STATE_VISIBLE,
|
expenses: STATE_VISIBLE,
|
||||||
expense_categories: STATE_VISIBLE,
|
expense_categories: STATE_VISIBLE,
|
||||||
cashier_attendance: STATE_VISIBLE,
|
cashier_attendance: STATE_VISIBLE,
|
||||||
technicians: STATE_LOCKED,
|
technicians: STATE_VISIBLE,
|
||||||
cashiers: STATE_LOCKED,
|
cashiers: STATE_VISIBLE,
|
||||||
collectors: STATE_LOCKED,
|
collectors: STATE_VISIBLE,
|
||||||
areas: STATE_VISIBLE,
|
areas: STATE_VISIBLE,
|
||||||
update: STATE_VISIBLE,
|
update: STATE_VISIBLE,
|
||||||
vouchers_discounts: STATE_VISIBLE,
|
vouchers_discounts: STATE_VISIBLE,
|
||||||
@@ -259,7 +261,7 @@ function enrichMenu(menu, states) {
|
|||||||
locked,
|
locked,
|
||||||
hidden,
|
hidden,
|
||||||
hrefResolved: menu.href,
|
hrefResolved: menu.href,
|
||||||
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Hubungi ${getFeatureContactPhone()} untuk mendapatkan password aktivasi.` : ''
|
lockedMessage: locked ? `Menu "${menu.labelDefault}" terkunci. Buka dari Pengaturan Sidebar.` : ''
|
||||||
};
|
};
|
||||||
if (Array.isArray(menu.children)) {
|
if (Array.isArray(menu.children)) {
|
||||||
out.children = menu.children.map((ch) => enrichMenu(ch, states));
|
out.children = menu.children.map((ch) => enrichMenu(ch, states));
|
||||||
@@ -267,12 +269,21 @@ function enrichMenu(menu, states) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCustomQrReady() {
|
||||||
|
try {
|
||||||
|
const enabled = getSetting('qris_static_enabled', true);
|
||||||
|
const off = enabled === false || enabled === 'false' || enabled === 0 || enabled === '0';
|
||||||
|
return !off && String(getSetting('qris_static_payload', '') || '').trim().length > 0;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
function getSidebarSections(session) {
|
function getSidebarSections(session) {
|
||||||
const states = getStoredMenuStates();
|
const states = getStoredMenuStates();
|
||||||
return SECTION_DEFINITIONS.map((section) => {
|
return SECTION_DEFINITIONS.map((section) => {
|
||||||
const items = MENU_DEFINITIONS
|
const items = MENU_DEFINITIONS
|
||||||
.filter((menu) => menu.section === section.key)
|
.filter((menu) => menu.section === section.key)
|
||||||
.filter((menu) => isMenuAllowedForSession(menu, session))
|
.filter((menu) => isMenuAllowedForSession(menu, session))
|
||||||
|
.filter((menu) => !(menu.key === 'custom_qr' && !isCustomQrReady()))
|
||||||
.map((menu) => {
|
.map((menu) => {
|
||||||
const enriched = enrichMenu(menu, states);
|
const enriched = enrichMenu(menu, states);
|
||||||
if (Array.isArray(enriched.children)) {
|
if (Array.isArray(enriched.children)) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const dns = require('dns');
|
||||||
|
try { dns.setDefaultResultOrder('ipv4first'); } catch (e) {}
|
||||||
const { CompatBot } = require('./telegramCompat');
|
const { CompatBot } = require('./telegramCompat');
|
||||||
const { getSetting, getNowLocal } = require('../config/settingsManager');
|
const { getSetting, getNowLocal } = require('../config/settingsManager');
|
||||||
const { logger } = require('../config/logger');
|
const { logger } = require('../config/logger');
|
||||||
@@ -76,10 +78,14 @@ function initTelegram() {
|
|||||||
}).catch(e => logger.error('Telegram Bot Error (getMe):', e.message));
|
}).catch(e => logger.error('Telegram Bot Error (getMe):', e.message));
|
||||||
}).catch(e => logger.error('Telegram Bot Error (deleteWebHook):', e.message));
|
}).catch(e => logger.error('Telegram Bot Error (deleteWebHook):', e.message));
|
||||||
|
|
||||||
// Middleware Admin Check (Fetch latest ID every time)
|
// Admin Check — dukung beberapa ID (pisahkan koma/titik-koma/spasi)
|
||||||
|
const getAdminIds = () => {
|
||||||
|
const raw = String(getSetting('telegram_admin_id', '') || getSetting('telegram_admin_chat_id', '') || '');
|
||||||
|
return raw.split(/[,;\s]+/).map(s => s.trim()).filter(Boolean);
|
||||||
|
};
|
||||||
const isAdmin = (msg) => {
|
const isAdmin = (msg) => {
|
||||||
const currentAdminId = getSetting('telegram_admin_id', '').toString();
|
const id = String(msg?.from?.id || '');
|
||||||
return msg.from.id.toString() === currentAdminId;
|
return !!id && getAdminIds().includes(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper Mikhmon Parser
|
// Helper Mikhmon Parser
|
||||||
@@ -1178,13 +1184,24 @@ function initTelegram() {
|
|||||||
// Export for manual re-init from settings
|
// Export for manual re-init from settings
|
||||||
async function notifyAdmin(text) {
|
async function notifyAdmin(text) {
|
||||||
try {
|
try {
|
||||||
const adminId = String(getSetting('telegram_admin_id', '') || '').trim();
|
const raw = String(getSetting('telegram_admin_id', '') || getSetting('telegram_admin_chat_id', '') || '');
|
||||||
if (!bot || !adminId || !text) return false;
|
const ids = raw.split(/[,;\s]+/).map(s => s.trim()).filter(Boolean);
|
||||||
const ids = adminId.split(/[,s]+/).filter(Boolean);
|
if (!bot || !ids.length || !text) return false;
|
||||||
|
|
||||||
|
// Telegram membatasi panjang pesan; pecah dengan aman.
|
||||||
|
const str = String(text);
|
||||||
|
const maxLen = 4000;
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < str.length; i += maxLen) chunks.push(str.slice(i, i + maxLen));
|
||||||
|
|
||||||
let ok = false;
|
let ok = false;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
try { await bot.sendMessage(id, text, { parse_mode: 'Markdown' }); ok = true; }
|
for (const chunk of chunks) {
|
||||||
catch (e) { try { await bot.sendMessage(id, String(text)); ok = true; } catch (e2) {} }
|
try { await bot.sendMessage(id, chunk, { parse_mode: 'Markdown' }); ok = true; }
|
||||||
|
catch (e) {
|
||||||
|
try { await bot.sendMessage(id, chunk); ok = true; } catch (e2) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
} catch (e) { return false; }
|
} catch (e) { return false; }
|
||||||
|
|||||||
@@ -140,8 +140,12 @@ async function isConnected() {
|
|||||||
try {
|
try {
|
||||||
if (gw === 'gowa') {
|
if (gw === 'gowa') {
|
||||||
const st = await require('./gowaWhatsappService').gowaStatus();
|
const st = await require('./gowaWhatsappService').gowaStatus();
|
||||||
const d = (st && st.data && st.data.results) || (st && st.data) || {};
|
const raw = (st && st.data && st.data.results) || (st && st.data) || {};
|
||||||
val = !!(d.is_connected || d.is_logged_in || d.connected);
|
const list = Array.isArray(raw) ? raw : [raw];
|
||||||
|
val = list.some(function (d) {
|
||||||
|
return !!(d && (d.is_connected || d.is_logged_in || d.connected ||
|
||||||
|
d.state === 'connected' || d.state === 'logged_in'));
|
||||||
|
});
|
||||||
} else if (gw === 'baileys') {
|
} else if (gw === 'baileys') {
|
||||||
const mod = await import('./whatsappBot.mjs');
|
const mod = await import('./whatsappBot.mjs');
|
||||||
const s = mod.whatsappStatus;
|
const s = mod.whatsappStatus;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.5.3
|
0.6.17
|
||||||
@@ -189,6 +189,7 @@
|
|||||||
<div class="act-menu">
|
<div class="act-menu">
|
||||||
<button type="button" class="act-item primary" onclick="refreshDevice('<%= d.id %>')"><i class="bi bi-arrow-repeat"></i> Refresh Device</button>
|
<button type="button" class="act-item primary" onclick="refreshDevice('<%= d.id %>')"><i class="bi bi-arrow-repeat"></i> Refresh Device</button>
|
||||||
<button type="button" class="act-item" onclick="openAddWanModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>', '<%= d.manufacturer || "" %>', '<%= d.model || "" %>')"><i class="bi bi-diagram-3"></i> Add WAN (All-in-One)</button>
|
<button type="button" class="act-item" onclick="openAddWanModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>', '<%= d.manufacturer || "" %>', '<%= d.model || "" %>')"><i class="bi bi-diagram-3"></i> Add WAN (All-in-One)</button>
|
||||||
|
<button type="button" class="act-item" onclick="openWifiModal('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>', '<%= d.sn %>')"><i class="bi bi-wifi"></i> Atur WiFi (SSID / Sandi)</button>
|
||||||
<div class="act-sep"></div>
|
<div class="act-sep"></div>
|
||||||
<button type="button" class="act-item danger" onclick="deleteDevice('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>')"><i class="bi bi-trash"></i> <%= t('admin.acs.del_device','Hapus Perangkat') %></button>
|
<button type="button" class="act-item danger" onclick="deleteDevice('<%= d.id %>', '<%= d.acs_server_id || selectedAcsId %>')"><i class="bi bi-trash"></i> <%= t('admin.acs.del_device','Hapus Perangkat') %></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +277,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unified Add WAN All-in-One Modal -->
|
<!-- Unified Add WAN All-in-One Modal -->
|
||||||
|
<div class="mo" id="wifiModal">
|
||||||
|
<div class="mb" style="max-width:520px">
|
||||||
|
<div class="mh">
|
||||||
|
<span class="mt"><i class="bi bi-wifi"></i> Atur WiFi ONU</span>
|
||||||
|
<button class="mc" onclick="closeModal('wifiModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="mbody">
|
||||||
|
<input type="hidden" id="wifiDeviceId">
|
||||||
|
<input type="hidden" id="wifiAcsId">
|
||||||
|
<div style="background:var(--sub-bg);border:1px dashed var(--border);border-radius:10px;padding:12px;margin-bottom:14px;font-size:13px">
|
||||||
|
<div><b>SN:</b> <span id="wifiSn" class="text-primary" style="font-weight:700">-</span></div>
|
||||||
|
<div style="margin-top:4px"><b>SSID saat ini:</b> <span id="wifiCurrentSsid" style="color:var(--muted)">Memuat…</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">SSID Baru (2.4G)</label>
|
||||||
|
<input class="fc" id="wifiSsidInput" placeholder="Nama WiFi baru">
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">SSID 5G otomatis menjadi <code><nama>-5G</code> jika didukung perangkat. SSID2/Guest tidak diubah.</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-p btn-sm" onclick="saveWifiSsid(this)"><i class="bi bi-save"></i> Simpan SSID</button>
|
||||||
|
<hr style="border-color:var(--border);margin:18px 0">
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Sandi WiFi Baru</label>
|
||||||
|
<input class="fc" type="password" id="wifiPassInput" placeholder="Minimal 8 karakter">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-p btn-sm" onclick="saveWifiPassword(this)"><i class="bi bi-shield-lock"></i> Simpan Sandi</button>
|
||||||
|
<div id="wifiMsg" style="margin-top:10px;font-size:12px;color:var(--muted)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('wifiModal')">Tutup</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mo" id="addWanModal">
|
<div class="mo" id="addWanModal">
|
||||||
<div class="mb" style="max-width:650px">
|
<div class="mb" style="max-width:650px">
|
||||||
<div class="mh">
|
<div class="mh">
|
||||||
@@ -1071,6 +1103,73 @@ async function bulkDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openWifiModal(deviceId, acsId, sn) {
|
||||||
|
document.getElementById('wifiDeviceId').value = deviceId;
|
||||||
|
document.getElementById('wifiAcsId').value = acsId || '';
|
||||||
|
document.getElementById('wifiSn').textContent = sn || deviceId;
|
||||||
|
document.getElementById('wifiSsidInput').value = '';
|
||||||
|
document.getElementById('wifiPassInput').value = '';
|
||||||
|
document.getElementById('wifiCurrentSsid').textContent = 'Memuat…';
|
||||||
|
document.getElementById('wifiMsg').textContent = '';
|
||||||
|
openModal('wifiModal');
|
||||||
|
fetch('/admin/acs/api/wifi-settings/' + encodeURIComponent(deviceId) + '?acsId=' + encodeURIComponent(acsId || ''))
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d || !d.success) {
|
||||||
|
document.getElementById('wifiCurrentSsid').textContent = (d && (d.message || d.error)) || 'Gagal memuat WiFi';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var bands = d.bands || [];
|
||||||
|
var b1 = bands.find(function (b) { return String(b.index) === '1'; }) || bands[0] || null;
|
||||||
|
var b5 = bands.find(function (b) { return String(b.index) === '5'; });
|
||||||
|
var parts = [];
|
||||||
|
if (b1) parts.push('2.4G: ' + String(b1.ssid || '-'));
|
||||||
|
if (b5) parts.push('5G: ' + String(b5.ssid || '-'));
|
||||||
|
document.getElementById('wifiCurrentSsid').textContent = parts.join(' • ') || '-';
|
||||||
|
if (b1) document.getElementById('wifiSsidInput').value = String(b1.ssid || '').replace(/-5G$/, '');
|
||||||
|
})
|
||||||
|
.catch(function (e) { document.getElementById('wifiCurrentSsid').textContent = 'Error: ' + e.message; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWifiSsid(btn) {
|
||||||
|
var id = document.getElementById('wifiDeviceId').value;
|
||||||
|
var ssid = String(document.getElementById('wifiSsidInput').value || '').trim();
|
||||||
|
var msg = document.getElementById('wifiMsg');
|
||||||
|
if (ssid.length < 2) { msg.textContent = 'SSID minimal 2 karakter.'; return; }
|
||||||
|
if (!confirm('Ubah SSID perangkat ini?')) return;
|
||||||
|
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan…';
|
||||||
|
msg.textContent = '';
|
||||||
|
try {
|
||||||
|
var r = await fetch('/admin/acs/api/device/' + encodeURIComponent(id) + '/ssid', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid: ssid })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
msg.style.color = d.success ? 'var(--success)' : 'var(--danger)';
|
||||||
|
msg.textContent = d.success ? d.message : ('Gagal: ' + (d.message || 'unknown'));
|
||||||
|
} catch (e) { msg.style.color = 'var(--danger)'; msg.textContent = 'Error: ' + e.message; }
|
||||||
|
finally { btn.disabled = false; btn.innerHTML = old; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWifiPassword(btn) {
|
||||||
|
var id = document.getElementById('wifiDeviceId').value;
|
||||||
|
var pass = String(document.getElementById('wifiPassInput').value || '').trim();
|
||||||
|
var msg = document.getElementById('wifiMsg');
|
||||||
|
if (pass.length < 8) { msg.textContent = 'Sandi minimal 8 karakter.'; return; }
|
||||||
|
if (!confirm('Ubah sandi WiFi perangkat ini?')) return;
|
||||||
|
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan…';
|
||||||
|
msg.textContent = '';
|
||||||
|
try {
|
||||||
|
var r = await fetch('/admin/acs/api/device/' + encodeURIComponent(id) + '/password', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pass })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
msg.style.color = d.success ? 'var(--success)' : 'var(--danger)';
|
||||||
|
msg.textContent = d.success ? d.message : ('Gagal: ' + (d.message || 'unknown'));
|
||||||
|
if (d.success) document.getElementById('wifiPassInput').value = '';
|
||||||
|
} catch (e) { msg.style.color = 'var(--danger)'; msg.textContent = 'Error: ' + e.message; }
|
||||||
|
finally { btn.disabled = false; btn.innerHTML = old; }
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteDevice(deviceId, acsId) {
|
async function deleteDevice(deviceId, acsId) {
|
||||||
if (!confirm('Hapus perangkat ' + deviceId + ' dari database ACS?')) return;
|
if (!confirm('Hapus perangkat ' + deviceId + ' dari database ACS?')) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
<option value="true" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
|
<option value="true" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
|
||||||
<option value="false" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
|
<option value="false" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
|
||||||
</select>
|
</select>
|
||||||
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.') %></div>
|
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak satu bulan setelah batas dihapus otomatis — harus daftar ulang.') %></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label class="fl"><i class="bi bi-exclamation-triangle"></i> <%= t('admin.automation.arrears_notice', 'Auto-Peringatan Tunggakan') %></label>
|
<label class="fl"><i class="bi bi-exclamation-triangle"></i> <%= t('admin.automation.arrears_notice', 'Auto-Peringatan Tunggakan') %></label>
|
||||||
|
|||||||
213
views/admin/custom_qr.ejs
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<%= lang || 'id' %>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title><%= title %> | <%= company %></title>
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
|
||||||
|
<link rel="stylesheet" href="/css/admin.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%- include('partials/sidebar', { activePage: activePage || 'custom_qr', company }) %>
|
||||||
|
<div class="mw">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-qr-code-simple"></i> QR-Custom Payment</div>
|
||||||
|
<div class="tb-right">
|
||||||
|
<span class="adm-badge"><i class="bi bi-person-fill"></i> Admin</span>
|
||||||
|
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<% if (msg) { %>
|
||||||
|
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
|
||||||
|
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof ready !== 'undefined' && !ready) { %>
|
||||||
|
<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> <b>QR-Custom Payment dinonaktifkan.</b> Aktifkan <b>QRIS Statis (Semi-Otomatis)</b> dan isi <b>QRIS static payload</b> di <a href="/admin/settings" style="color:inherit;text-decoration:underline">Pengaturan</a> terlebih dahulu.</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof ready === 'undefined' || ready) { %>
|
||||||
|
<div class="card mb16">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> Buat QR Nominal Custom</h6></div>
|
||||||
|
<div class="card-bd">
|
||||||
|
<form method="POST" action="/admin/custom-qr">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg" style="flex:2"><label class="fl">Judul *</label><input class="fc" name="title" required placeholder="mis. Pembayaran sewa tower / donasi perusahaan"></div>
|
||||||
|
<div class="fg"><label class="fl">Kategori</label><input class="fc" name="category" placeholder="mis. Sewa, Donasi, Lain-lain"></div>
|
||||||
|
<div class="fg"><label class="fl">Nominal (Rp) *</label><input class="fc" type="number" min="1" name="amount" required placeholder="100000"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg" style="flex:2"><label class="fl">Catatan (opsional)</label><input class="fc" name="note" placeholder="Keterangan untuk penerima link"></div>
|
||||||
|
<div class="fg" style="display:flex;align-items:flex-end"><button class="btn btn-p" type="submit"><i class="bi bi-qr-code"></i> Generate QR (20 menit)</button></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="form-row mb16">
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Pembayaran masuk</div><div style="font-size:22px;font-weight:800"><%= stats.paidCount %></div><div class="muted" style="font-size:11px">Rp <%= Number(stats.paidTotal||0).toLocaleString('id-ID') %></div></div></div></div>
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Menunggu (pending)</div><div style="font-size:22px;font-weight:800;color:#fbbf24"><%= stats.pendingCount %></div></div></div></div>
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Warning (lewat 20 menit)</div><div style="font-size:22px;font-weight:800;color:#fb923c"><%= stats.warningCount %></div></div></div></div>
|
||||||
|
<div class="fg"><div class="card"><div class="card-bd"><div class="muted" style="font-size:12px">Ditolak / refund</div><div style="font-size:22px;font-weight:800"><%= stats.rejectedCount %></div></div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (detail) { %>
|
||||||
|
<% const cqExpired = detail.status === 'expired' || (detail.status === 'pending' && detail.expires_at && new Date(detail.expires_at).getTime() < Date.now()); %>
|
||||||
|
<div class="card mb16">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %><% if (!cqExpired) { %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %><% } %></h6></div>
|
||||||
|
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
|
||||||
|
<div style="text-align:center">
|
||||||
|
<% if (detail.status === 'paid' || detail.status === 'rejected' || cqExpired) { %>
|
||||||
|
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">
|
||||||
|
<% if (cqExpired) { %>QR kedaluwarsa — nominal & QR disembunyikan.<% } else { %>QR tidak aktif — status: <b><%= detail.status %></b><% } %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
|
||||||
|
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:13px;line-height:1.8">
|
||||||
|
<div><b>Status:</b> <%= detail.status %></div>
|
||||||
|
<% if (!cqExpired) { %>
|
||||||
|
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
|
||||||
|
<div><b>Kode unik:</b> <%= detail.unique_code %></div>
|
||||||
|
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
|
||||||
|
<% } %>
|
||||||
|
<div><b>Kategori:</b> <%= detail.category || '-' %></div>
|
||||||
|
<div><b>Dibuat:</b> <%= detail.created_at %></div>
|
||||||
|
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
|
||||||
|
<% if (detail.note) { %><div><b>Catatan:</b> <%= detail.note %></div><% } %>
|
||||||
|
<div style="margin-top:8px"><b>Link share:</b><br>
|
||||||
|
<input class="fc" readonly id="cqLink" value="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" style="font-size:12px">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button class="btn btn-g btn-sm" onclick="copyCqLink()"><i class="bi bi-copy"></i> Salin Link</button>
|
||||||
|
<a class="btn btn-g btn-sm" href="<%= baseUrl %>/custom-qr/<%= detail.share_token %>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right"></i> Buka</a>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px">
|
||||||
|
<% if (detail.status !== 'paid') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/manual-paid" onsubmit="return confirm('Tandai pembayaran ini LUNAS secara manual?')">
|
||||||
|
<input type="hidden" name="note" value="Manual pay dari panel admin">
|
||||||
|
<button class="btn btn-p btn-sm"><i class="bi bi-check2-circle"></i> Manual Pay</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (detail.status === 'warning') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/accept" onsubmit="return confirm('Terima pembayaran setelah 20 menit?')">
|
||||||
|
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (detail.status !== 'rejected') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
|
||||||
|
<input type="hidden" name="note" value="Refund dari panel admin">
|
||||||
|
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (cqExpired) { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/regenerate" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
|
||||||
|
<button class="btn btn-p btn-sm"><i class="bi bi-arrow-repeat"></i> Regenerate QR</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/delete" onsubmit="return confirm('Hapus QR ini dari histori?')" style="display:flex;gap:6px;margin-top:12px;flex-wrap:wrap">
|
||||||
|
<input class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:200px">
|
||||||
|
<button class="btn btn-g btn-sm"><i class="bi bi-trash"></i> Hapus QR ini</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
|
||||||
|
<h6 style="margin:0"><i class="bi bi-clock-history"></i> Histori QR Custom</h6>
|
||||||
|
<form method="POST" action="/admin/custom-qr/reset" onsubmit="return confirm('Reset SEMUA histori QR custom? Tindakan ini tidak bisa dibatalkan.')" style="display:flex;gap:6px;align-items:center">
|
||||||
|
<input class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
|
||||||
|
<button class="btn btn-g btn-sm"><i class="bi bi-trash3"></i> Reset Histori</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-bd" style="border-bottom:1px solid var(--border)">
|
||||||
|
<form method="GET" action="/admin/custom-qr" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
|
||||||
|
<div class="fg"><label class="fl">Dari tanggal</label><input class="fc" type="date" name="from" value="<%= filterFrom || '' %>"></div>
|
||||||
|
<div class="fg"><label class="fl">Sampai tanggal</label><input class="fc" type="date" name="to" value="<%= filterTo || '' %>"></div>
|
||||||
|
<button class="btn btn-p btn-sm" type="submit"><i class="bi bi-funnel"></i> Filter</button>
|
||||||
|
<a class="btn btn-g btn-sm" href="/admin/custom-qr"><i class="bi bi-x-circle"></i> Reset Filter</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form id="cqBulkForm" method="POST" action="/admin/custom-qr/delete-bulk"></form>
|
||||||
|
<div class="card-bd" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border)">
|
||||||
|
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="cqAll" onclick="cqToggleAll(this)"> Pilih semua
|
||||||
|
</label>
|
||||||
|
<input form="cqBulkForm" class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
|
||||||
|
<input form="cqBulkForm" type="hidden" name="from" value="<%= filterFrom || '' %>">
|
||||||
|
<input form="cqBulkForm" type="hidden" name="to" value="<%= filterTo || '' %>">
|
||||||
|
<button form="cqBulkForm" class="btn btn-g btn-sm" onclick="return confirm('Hapus QR yang dipilih?')"><i class="bi bi-trash"></i> Hapus Terpilih</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-bd" style="overflow:auto">
|
||||||
|
<table class="table" style="width:100%;font-size:12.5px">
|
||||||
|
<thead><tr><th style="width:30px"></th><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (!rows || rows.length === 0) { %>
|
||||||
|
<tr><td colspan="8" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
|
||||||
|
<% } %>
|
||||||
|
<% rows.forEach(function(r) { %>
|
||||||
|
<tr>
|
||||||
|
<td><input type="checkbox" class="cq-check" form="cqBulkForm" name="ids" value="<%= r.id %>"></td>
|
||||||
|
<td><b><%= r.title %></b><% if (r.note) { %><div class="muted" style="font-size:11px"><%= r.note %></div><% } %></td>
|
||||||
|
<td><%= r.category || '-' %></td>
|
||||||
|
<td>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %></td>
|
||||||
|
<td>
|
||||||
|
<span style="font-weight:700;color:<%= r.status==='paid'?'#4ade80':(r.status==='warning'?'#fb923c':(r.status==='rejected'?'#94a3b8':(r.status==='expired'?'#f87171':'#fbbf24'))) %>"><%= r.status %></span>
|
||||||
|
</td>
|
||||||
|
<td><%= r.created_at %></td>
|
||||||
|
<td><%= r.expires_at || '-' %></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<a class="btn btn-g btn-sm" href="/admin/custom-qr?id=<%= r.id %>"><i class="bi bi-eye"></i></a>
|
||||||
|
<% if (r.status !== 'paid' && r.status !== 'rejected') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= r.id %>/manual-paid" style="display:inline" onsubmit="return confirm('Tandai LUNAS manual?')">
|
||||||
|
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% if (r.status === 'expired') { %>
|
||||||
|
<form method="POST" action="/admin/custom-qr/<%= r.id %>/regenerate" style="display:inline" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
|
||||||
|
<button class="btn btn-g btn-sm" title="Regenerate"><i class="bi bi-arrow-repeat"></i></button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function cqToggleAll(el) {
|
||||||
|
var on = !!(el && el.checked);
|
||||||
|
document.querySelectorAll('.cq-check').forEach(function (c) { c.checked = on; });
|
||||||
|
}
|
||||||
|
function copyCqLink() {
|
||||||
|
var el = document.getElementById('cqLink');
|
||||||
|
if (!el) return;
|
||||||
|
try { el.select(); document.execCommand('copy'); } catch (e) {}
|
||||||
|
if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(el.value).catch(function () {});
|
||||||
|
}
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('cqTimer');
|
||||||
|
if (!el) return;
|
||||||
|
var exp = el.getAttribute('data-expires');
|
||||||
|
function tick() {
|
||||||
|
if (!exp) { el.textContent = ''; return; }
|
||||||
|
var d = new Date(exp).getTime() - Date.now();
|
||||||
|
if (d <= 0) { el.textContent = 'Lewat 20 menit — pembayaran tetap dicek.'; return; }
|
||||||
|
var m = Math.floor(d / 60000), s = Math.floor((d % 60000) / 1000);
|
||||||
|
el.textContent = 'Sisa waktu: ' + m + ' menit ' + s + ' detik';
|
||||||
|
}
|
||||||
|
tick(); setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -129,7 +129,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<i class="bi bi-box-arrow-left"></i><span><%= t('common.logout', 'Logout') %></span>
|
<i class="bi bi-box-arrow-left"></i><span><%= t('common.logout', 'Logout') %></span>
|
||||||
</a>
|
</a>
|
||||||
<div class="mt-4 px-3 py-2 text-center" style="border-top: 1px solid rgba(255,255,255,0.1);">
|
<div class="mt-4 px-3 py-2 text-center" style="border-top: 1px solid rgba(255,255,255,0.1);">
|
||||||
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
|
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
|
||||||
OPEN-ISP BILLING SYSTEM
|
OPEN-ISP BILLING SYSTEM
|
||||||
</a>
|
</a>
|
||||||
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
|
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
|
||||||
@@ -163,7 +163,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<%= t('admin.lock.code_label','Kode aktivasi menu ini') %> <strong style="color:#86efac">GRATIS</strong> <%= t('admin.lock.wiki_note','— tanpa perlu donasi untuk membukanya.') %>
|
<%= t('admin.lock.code_label','Kode aktivasi menu ini') %> <strong style="color:#86efac">GRATIS</strong> <%= t('admin.lock.wiki_note','— tanpa perlu donasi untuk membukanya.') %>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:8px">
|
<div style="margin-top:14px;display:flex;flex-direction:column;gap:8px">
|
||||||
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
|
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
|
||||||
<i class="bi bi-git"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
|
<i class="bi bi-git"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/sidebar-settings" class="btn btn-g" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px" onclick="hideLockedMenuNotice()">
|
<a href="/admin/sidebar-settings" class="btn btn-g" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px" onclick="hideLockedMenuNotice()">
|
||||||
@@ -241,3 +241,31 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div id="restart-fab" style="display:none;position:fixed;right:18px;bottom:84px;z-index:9999;max-width:330px">
|
||||||
|
<div style="background:#7c2d12;color:#fff;border:1px solid #f59e0b;border-radius:12px;padding:12px 14px;box-shadow:0 10px 30px rgba(0,0,0,.35);font-size:13px;line-height:1.45">
|
||||||
|
<b><i class="bi bi-exclamation-triangle-fill"></i> Billing perlu restart</b>
|
||||||
|
<div id="restart-fab-text" style="margin-top:4px;color:#fde68a">Update sudah diterapkan, tetapi kode lama masih berjalan.</div>
|
||||||
|
<a href="/admin/update" style="display:inline-block;margin-top:8px;background:#f59e0b;color:#111;font-weight:700;padding:6px 10px;border-radius:8px;text-decoration:none">Cara restart</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function checkRestartRequired() {
|
||||||
|
fetch('/admin/api/restart-required', { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var el = document.getElementById('restart-fab');
|
||||||
|
if (!el) return;
|
||||||
|
el.style.display = (d && d.pending) ? 'block' : 'none';
|
||||||
|
if (d && d.pending && d.version) {
|
||||||
|
var t = document.getElementById('restart-fab-text');
|
||||||
|
if (t) t.textContent = 'Update v' + d.version + ' sudah diterapkan. Restart aplikasi agar fitur/fix baru aktif.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
checkRestartRequired();
|
||||||
|
setInterval(checkRestartRequired, 30000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -527,6 +527,9 @@
|
|||||||
<button type="button" class="btn btn-sm btn-g" id="tab-btn-script" onclick="switchIsolirTab('script')">
|
<button type="button" class="btn btn-sm btn-g" id="tab-btn-script" onclick="switchIsolirTab('script')">
|
||||||
<i class="bi bi-filetype-txt me-1"></i> Copy Script RouterOS (.rsc)
|
<i class="bi bi-filetype-txt me-1"></i> Copy Script RouterOS (.rsc)
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-g" id="tab-btn-cloud" onclick="switchIsolirTab('cloud')">
|
||||||
|
<i class="bi bi-cloud-arrow-up me-1"></i> Cloud (Sederhana)
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TAB 1: Auto Setup via API -->
|
<!-- TAB 1: Auto Setup via API -->
|
||||||
@@ -554,6 +557,23 @@
|
|||||||
<div id="isolir-auto-setup-result" style="display:none;"></div>
|
<div id="isolir-auto-setup-result" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 3: Billing Cloud (script sederhana) -->
|
||||||
|
<div id="isolir-tab-cloud" style="display:none;">
|
||||||
|
<div class="alert alert-info p-3 small mb-3" style="background:rgba(56,189,248,0.10); border:1px solid rgba(56,189,248,0.3); color:#e0f2fe;">
|
||||||
|
<h6 class="fw-bold mb-1"><i class="bi bi-cloud me-1"></i> Billing di Cloud</h6>
|
||||||
|
<p class="mb-1 small">Script sederhana: DNS diarahkan ke router, HTTP pelanggan isolir di-NAT ke <b>redirector billing</b>, HTTPS ditolak agar browser fallback ke HTTP.</p>
|
||||||
|
<p class="mb-0 small">Syarat: port redirector (default <b>8099</b>) harus bisa diakses dari publik, dan <code>ISOLIR_REDIRECT_URL</code> di billing diisi <code>https://domain/isolated</code>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<div class="fg" style="flex:2;min-width:200px"><label class="fl">Domain / IP publik billing (disarankan IP origin, bukan CDN)</label><input class="fc" id="ic-host" value="<%= typeof defaultPublicBase !== 'undefined' ? defaultPublicBase : '' %>" placeholder="https://billing.domain.com"></div>
|
||||||
|
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Port redirector</label><input class="fc" id="ic-port" type="number" value="8099"></div>
|
||||||
|
<div class="fg" style="flex:1;min-width:100px"><label class="fl">Profil PPPoE</label><input class="fc" id="ic-profile" value="isolir"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-p btn-sm my-2" onclick="loadIsolirCloudScript(this)"><i class="bi bi-magic"></i> Generate Script Cloud</button>
|
||||||
|
<div id="isolir-cloud-meta" class="small text-muted mb-2"></div>
|
||||||
|
<textarea id="isolir-cloud-body" class="fc" readonly rows="18" style="width:100%;font-family:ui-monospace,monospace;font-size:12px;background:rgba(15,23,42,0.9);color:#818cf8;border-radius:8px;"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- TAB 2: RouterOS Manual Script (.rsc) -->
|
<!-- TAB 2: RouterOS Manual Script (.rsc) -->
|
||||||
<div id="isolir-tab-script" style="display:none;">
|
<div id="isolir-tab-script" style="display:none;">
|
||||||
<p class="small text-muted mb-2"><%= t('admin.rt.script_hint','Script ini di-generate otomatis berdasarkan IP server & URL billing (app_url). Anda dapat menyalin dan menempelkannya di Winbox Terminal MikroTik.') %></p>
|
<p class="small text-muted mb-2"><%= t('admin.rt.script_hint','Script ini di-generate otomatis berdasarkan IP server & URL billing (app_url). Anda dapat menyalin dan menempelkannya di Winbox Terminal MikroTik.') %></p>
|
||||||
@@ -563,6 +583,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mf">
|
<div class="mf">
|
||||||
<button type="button" class="btn btn-g" onclick="closeModal('isolirScriptModal')"><%= t('common.close', 'Tutup') %></button>
|
<button type="button" class="btn btn-g" onclick="closeModal('isolirScriptModal')"><%= t('common.close', 'Tutup') %></button>
|
||||||
|
<button type="button" class="btn btn-p" id="btn-copy-cloud-modal" style="display:none;" onclick="copyIsolirCloudScript()"><i class="bi bi-clipboard me-1"></i> Salin Script Cloud</button>
|
||||||
<button type="button" class="btn btn-p" id="btn-copy-script-modal" style="display:none;" onclick="copyIsolirScript()"><i class="bi bi-clipboard me-1"></i> <%= t('admin.routers.isolir_script_copy', 'Salin script') %></button>
|
<button type="button" class="btn btn-p" id="btn-copy-script-modal" style="display:none;" onclick="copyIsolirScript()"><i class="bi bi-clipboard me-1"></i> <%= t('admin.routers.isolir_script_copy', 'Salin script') %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,27 +912,60 @@ async function testConn(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function switchIsolirTab(tab) {
|
function switchIsolirTab(tab) {
|
||||||
const autoTab = document.getElementById('isolir-tab-auto');
|
const panels = {
|
||||||
const scriptTab = document.getElementById('isolir-tab-script');
|
auto: document.getElementById('isolir-tab-auto'),
|
||||||
const autoBtn = document.getElementById('tab-btn-auto');
|
script: document.getElementById('isolir-tab-script'),
|
||||||
const scriptBtn = document.getElementById('tab-btn-script');
|
cloud: document.getElementById('isolir-tab-cloud')
|
||||||
const copyBtn = document.getElementById('btn-copy-script-modal');
|
};
|
||||||
|
const btns = {
|
||||||
|
auto: document.getElementById('tab-btn-auto'),
|
||||||
|
script: document.getElementById('tab-btn-script'),
|
||||||
|
cloud: document.getElementById('tab-btn-cloud')
|
||||||
|
};
|
||||||
|
const copyScript = document.getElementById('btn-copy-script-modal');
|
||||||
|
const copyCloud = document.getElementById('btn-copy-cloud-modal');
|
||||||
|
Object.keys(panels).forEach(function (k) {
|
||||||
|
if (panels[k]) panels[k].style.display = (k === tab) ? 'block' : 'none';
|
||||||
|
if (btns[k]) btns[k].className = 'btn btn-sm ' + (k === tab ? 'btn-p' : 'btn-g');
|
||||||
|
});
|
||||||
|
if (copyScript) copyScript.style.display = tab === 'script' ? 'inline-block' : 'none';
|
||||||
|
if (copyCloud) copyCloud.style.display = tab === 'cloud' ? 'inline-block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
if (tab === 'auto') {
|
async function loadIsolirCloudScript(btn) {
|
||||||
autoTab.style.display = 'block';
|
const hostEl = document.getElementById('ic-host');
|
||||||
scriptTab.style.display = 'none';
|
const portEl = document.getElementById('ic-port');
|
||||||
autoBtn.className = 'btn btn-sm btn-p';
|
const profEl = document.getElementById('ic-profile');
|
||||||
scriptBtn.className = 'btn btn-sm btn-g';
|
const body = document.getElementById('isolir-cloud-body');
|
||||||
if (copyBtn) copyBtn.style.display = 'none';
|
const meta = document.getElementById('isolir-cloud-meta');
|
||||||
} else {
|
const old = btn ? btn.innerHTML : '';
|
||||||
autoTab.style.display = 'none';
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Membuat...'; }
|
||||||
scriptTab.style.display = 'block';
|
if (body) body.value = '';
|
||||||
autoBtn.className = 'btn btn-sm btn-g';
|
if (meta) meta.textContent = 'Membuat script...';
|
||||||
scriptBtn.className = 'btn btn-sm btn-p';
|
try {
|
||||||
if (copyBtn) copyBtn.style.display = 'inline-block';
|
const url = '/admin/api/isolir-cloud-script?host=' + encodeURIComponent(hostEl ? hostEl.value : '') +
|
||||||
|
'&port=' + encodeURIComponent(portEl ? portEl.value : '') +
|
||||||
|
'&profile=' + encodeURIComponent(profEl ? profEl.value : '');
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) throw new Error(data.error || 'Gagal membuat script.');
|
||||||
|
if (body) body.value = data.script || '';
|
||||||
|
if (meta) meta.textContent = 'Billing: ' + (data.billingHost || '-') + ' → ' + ((data.billingIps || [data.billingIp]).join(', ')) + ' | redirect port ' + data.redirectPort + ' | ' + (data.isolirUrl || '') + (data.warning ? ' — ' + data.warning : '');
|
||||||
|
} catch (e) {
|
||||||
|
if (meta) meta.textContent = 'Error: ' + e.message;
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = old; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyIsolirCloudScript() {
|
||||||
|
const ta = document.getElementById('isolir-cloud-body');
|
||||||
|
if (!ta || !ta.value) return;
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); } catch (e) {}
|
||||||
|
alert('Script cloud disalin ke clipboard.');
|
||||||
|
}
|
||||||
|
|
||||||
async function setupFirewall(routerId) {
|
async function setupFirewall(routerId) {
|
||||||
openIsolirScriptModal(routerId);
|
openIsolirScriptModal(routerId);
|
||||||
switchIsolirTab('auto');
|
switchIsolirTab('auto');
|
||||||
|
|||||||
@@ -68,6 +68,43 @@
|
|||||||
<input class="fc" name="notice_note" id="n_note" value="<%= settings.notice_note || '' %>" placeholder="<%= t('admin.service_notice.note_ph', 'Kosongkan untuk teks bawaan') %>">
|
<input class="fc" name="notice_note" id="n_note" value="<%= settings.notice_note || '' %>" placeholder="<%= t('admin.service_notice.note_ph', 'Kosongkan untuk teks bawaan') %>">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row" style="margin-top:4px">
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl" style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" name="notice_wa" value="1" id="n_wa">
|
||||||
|
Kirim notifikasi WhatsApp ke pelanggan saat status berubah
|
||||||
|
</label>
|
||||||
|
<div class="hint">Terkirim otomatis saat mode diubah (gangguan/perbaikan, atau kembali NORMAL = layanan pulih).</div>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Target Notifikasi</label>
|
||||||
|
<select class="fs" name="notice_wa_target" id="n_wa_target">
|
||||||
|
<option value="all">Semua pelanggan aktif</option>
|
||||||
|
<option value="area">Per area</option>
|
||||||
|
<option value="router">Per router</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="n_wa_extra" style="display:none">
|
||||||
|
<div class="fg" id="n_wa_area_wrap">
|
||||||
|
<label class="fl">Area</label>
|
||||||
|
<select class="fs" name="notice_wa_area">
|
||||||
|
<option value="">— pilih area —</option>
|
||||||
|
<% (areas || []).forEach(function(a){ %>
|
||||||
|
<option value="<%= a %>"><%= a %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fg" id="n_wa_router_wrap" style="display:none">
|
||||||
|
<label class="fl">Router</label>
|
||||||
|
<select class="fs" name="notice_wa_router">
|
||||||
|
<option value="">— pilih router —</option>
|
||||||
|
<% (routers || []).forEach(function(r){ %>
|
||||||
|
<option value="<%= r.id %>"><%= r.name %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-bd" style="border-top:1px solid var(--border);padding-top:16px;text-align:right">
|
<div class="card-bd" style="border-top:1px solid var(--border);padding-top:16px;text-align:right">
|
||||||
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
|
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
|
||||||
@@ -140,6 +177,19 @@
|
|||||||
[els.mode, els.start, els.interval, els.note].forEach(function(el){ el.addEventListener(ev, render); });
|
[els.mode, els.start, els.interval, els.note].forEach(function(el){ el.addEventListener(ev, render); });
|
||||||
});
|
});
|
||||||
render();
|
render();
|
||||||
|
|
||||||
|
var waTarget = document.getElementById('n_wa_target');
|
||||||
|
var waExtra = document.getElementById('n_wa_extra');
|
||||||
|
var waArea = document.getElementById('n_wa_area_wrap');
|
||||||
|
var waRouter = document.getElementById('n_wa_router_wrap');
|
||||||
|
function renderWaTarget(){
|
||||||
|
var v = waTarget.value;
|
||||||
|
waExtra.style.display = v === 'all' ? 'none' : 'flex';
|
||||||
|
waArea.style.display = v === 'area' ? '' : 'none';
|
||||||
|
waRouter.style.display = v === 'router' ? '' : 'none';
|
||||||
|
}
|
||||||
|
waTarget.addEventListener('change', renderWaTarget);
|
||||||
|
renderWaTarget();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -173,6 +173,7 @@
|
|||||||
<button type="button" class="set-tab" data-cat="notif" onclick="setSetTab('notif')"><i class="bi bi-bell"></i> <%= t('admin.settings.tab_notif','Notifikasi & WhatsApp') %></button>
|
<button type="button" class="set-tab" data-cat="notif" onclick="setSetTab('notif')"><i class="bi bi-bell"></i> <%= t('admin.settings.tab_notif','Notifikasi & WhatsApp') %></button>
|
||||||
<button type="button" class="set-tab" data-cat="keamanan" onclick="setSetTab('keamanan')"><i class="bi bi-shield-lock"></i> <%= t('admin.settings.tab_security','Keamanan') %></button>
|
<button type="button" class="set-tab" data-cat="keamanan" onclick="setSetTab('keamanan')"><i class="bi bi-shield-lock"></i> <%= t('admin.settings.tab_security','Keamanan') %></button>
|
||||||
<button type="button" class="set-tab" data-cat="pembayaran" onclick="setSetTab('pembayaran')"><i class="bi bi-wallet2"></i> <%= t('admin.settings.tab_payment','Pembayaran') %></button>
|
<button type="button" class="set-tab" data-cat="pembayaran" onclick="setSetTab('pembayaran')"><i class="bi bi-wallet2"></i> <%= t('admin.settings.tab_payment','Pembayaran') %></button>
|
||||||
|
<button type="button" class="set-tab" data-cat="ai" onclick="setSetTab('ai')"><i class="bi bi-robot"></i> AI</button>
|
||||||
<button type="button" class="set-tab" data-cat="branding" onclick="setSetTab('branding')"><i class="bi bi-palette"></i> Branding</button>
|
<button type="button" class="set-tab" data-cat="branding" onclick="setSetTab('branding')"><i class="bi bi-palette"></i> Branding</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-search">
|
<div class="set-search">
|
||||||
@@ -209,7 +210,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="card mb16">
|
<div class="card mb16" data-setcat="ai">
|
||||||
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI & Self-Healing</h6></div>
|
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI & Self-Healing</h6></div>
|
||||||
<div class="card-bd settings-grid">
|
<div class="card-bd settings-grid">
|
||||||
<div>
|
<div>
|
||||||
@@ -237,6 +238,10 @@
|
|||||||
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
|
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
|
||||||
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" type="password" autocomplete="new-password" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
|
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" type="password" autocomplete="new-password" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
|
||||||
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
|
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px">
|
||||||
|
<button type="button" class="btn btn-g" onclick="testAiApi(this)"><i class="bi bi-plug"></i> Test API</button>
|
||||||
|
<span id="ai-test-status" style="font-size:12px;color:var(--muted)">Menguji API key & model yang sedang diisi (tanpa simpan).</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
|
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
|
||||||
@@ -268,11 +273,26 @@
|
|||||||
<label class="fl"><%= t('admin.settings.company_name', 'Nama Aplikasi / Perusahaan') %></label>
|
<label class="fl"><%= t('admin.settings.company_name', 'Nama Aplikasi / Perusahaan') %></label>
|
||||||
<input class="fc" name="company_header" value="<%= settings.company_header || '' %>">
|
<input class="fc" name="company_header" value="<%= settings.company_header || '' %>">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Nama PT / Sub-header (kop invoice & PDF)</label>
|
||||||
|
<input class="fc" name="company_subheader" value="<%= settings.company_subheader || '' %>" placeholder="Contoh: PT Nama Perusahaan">
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-top:4px">Dipakai pada kop invoice/PDF. Bisa berbeda tiap instalasi.</div>
|
||||||
|
</div>
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label class="fl"><%= t('admin.set.public_url','URL Publik Aplikasi (Public Base URL)') %></label>
|
<label class="fl"><%= t('admin.set.public_url','URL Publik Aplikasi (Public Base URL)') %></label>
|
||||||
<input class="fc" name="public_base_url" value="<%= settings.public_base_url || '' %>" placeholder="https://portal.domain-anda.com">
|
<input class="fc" name="public_base_url" value="<%= settings.public_base_url || '' %>" placeholder="https://portal.domain-anda.com">
|
||||||
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.link_hint','Digunakan untuk menyusun tautan login pelanggan yang dikirim via WhatsApp. Contoh: ') %><span class="mono">https://portal.domain-anda.com</span></div>
|
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.link_hint','Digunakan untuk menyusun tautan login pelanggan yang dikirim via WhatsApp. Contoh: ') %><span class="mono">https://portal.domain-anda.com</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">URL Halaman Isolir</label>
|
||||||
|
<input class="fc" name="isolir_portal_url" value="<%= settings.isolir_portal_url || '' %>" placeholder="https://domain-anda/isolated">
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-top:4px">Tujuan redirect pelanggan terisolir (dipakai redirector & generator script cloud).</div>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Port Redirector Isolir</label>
|
||||||
|
<input class="fc" type="number" min="1" max="65535" name="isolir_redirect_port" value="<%= settings.isolir_redirect_port || 8099 %>">
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-top:4px">Port server redirector di billing (default 8099). Ubah NAT MikroTik bila port diganti.</div>
|
||||||
|
</div>
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label class="fl"><%= t('admin.set.id_prefix','Prefix ID Pelanggan') %></label>
|
<label class="fl"><%= t('admin.set.id_prefix','Prefix ID Pelanggan') %></label>
|
||||||
<input class="fc" name="customer_id_prefix" value="<%= settings.customer_id_prefix || 'SKY' %>" placeholder="SKY" style="text-transform:uppercase" maxlength="12">
|
<input class="fc" name="customer_id_prefix" value="<%= settings.customer_id_prefix || 'SKY' %>" placeholder="SKY" style="text-transform:uppercase" maxlength="12">
|
||||||
@@ -372,6 +392,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GenieACS & MikroTik -->
|
<!-- GenieACS & MikroTik -->
|
||||||
|
<div class="card mb16" data-setcat="umum">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-window-desktop"></i> Kustomisasi Beranda & Portal Pelanggan</h6></div>
|
||||||
|
<div class="card-bd settings-grid">
|
||||||
|
<div>
|
||||||
|
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-megaphone"></i> Banner & Status</h6>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Banner Pengumuman Portal</label>
|
||||||
|
<select class="fs" name="portal_banner_enabled">
|
||||||
|
<option value="false" <%= !settings.portal_banner_enabled ? 'selected' : '' %>>Nonaktif</option>
|
||||||
|
<option value="true" <%= settings.portal_banner_enabled ? 'selected' : '' %>>Aktif</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Tipe Banner</label>
|
||||||
|
<select class="fs" name="portal_banner_type">
|
||||||
|
<option value="info" <%= (settings.portal_banner_type === 'info' || !settings.portal_banner_type) ? 'selected' : '' %>>Info (Biru)</option>
|
||||||
|
<option value="warning" <%= settings.portal_banner_type === 'warning' ? 'selected' : '' %>>Peringatan (Kuning)</option>
|
||||||
|
<option value="danger" <%= settings.portal_banner_type === 'danger' ? 'selected' : '' %>>Gangguan (Merah)</option>
|
||||||
|
<option value="success" <%= settings.portal_banner_type === 'success' ? 'selected' : '' %>>Promo (Hijau)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Teks Banner</label>
|
||||||
|
<textarea class="fc" name="portal_banner_text" rows="2" placeholder="Contoh: Maintenance jaringan Kamis 01.00-04.00 WIB."><%= settings.portal_banner_text || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Status Jaringan</label>
|
||||||
|
<select class="fs" name="portal_network_status">
|
||||||
|
<option value="normal" <%= (settings.portal_network_status === 'normal' || !settings.portal_network_status) ? 'selected' : '' %>>🟢 Normal</option>
|
||||||
|
<option value="maintenance" <%= settings.portal_network_status === 'maintenance' ? 'selected' : '' %>>🟠 Pemeliharaan</option>
|
||||||
|
<option value="issue" <%= settings.portal_network_status === 'issue' ? 'selected' : '' %>>🔴 Ada Kendala</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Keterangan Status (opsional)</label>
|
||||||
|
<input class="fc" name="portal_network_status_text" value="<%= settings.portal_network_status_text || '' %>" placeholder="Kosongkan untuk teks bawaan">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-card-heading"></i> Hero & Media Sosial</h6>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Badge Kecil</label>
|
||||||
|
<input class="fc" name="portal_hero_badge" value="<%= settings.portal_hero_badge || '' %>" placeholder="Portal Pelanggan">
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Judul Utama</label>
|
||||||
|
<input class="fc" name="portal_hero_title" value="<%= settings.portal_hero_title || '' %>" placeholder="Kosongkan untuk teks bawaan">
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Deskripsi</label>
|
||||||
|
<textarea class="fc" name="portal_hero_subtitle" rows="2" placeholder="Kosongkan untuk teks bawaan"><%= settings.portal_hero_subtitle || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Link Google Maps Kantor</label>
|
||||||
|
<input class="fc" name="social_maps_url" value="<%= settings.social_maps_url || '' %>" placeholder="https://maps.google.com/...">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg"><label class="fl">Instagram</label><input class="fc" name="social_instagram" value="<%= settings.social_instagram || '' %>" placeholder="https://instagram.com/..."></div>
|
||||||
|
<div class="fg"><label class="fl">Facebook</label><input class="fc" name="social_facebook" value="<%= settings.social_facebook || '' %>" placeholder="https://facebook.com/..."></div>
|
||||||
|
</div>
|
||||||
|
<div class="fg"><label class="fl">TikTok</label><input class="fc" name="social_tiktok" value="<%= settings.social_tiktok || '' %>" placeholder="https://tiktok.com/@..."></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div data-setcat="jaringan">
|
<div data-setcat="jaringan">
|
||||||
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-router"></i> <%= t('admin.settings.external_api', 'API Eksternal') %></h6>
|
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-router"></i> <%= t('admin.settings.external_api', 'API Eksternal') %></h6>
|
||||||
|
|
||||||
@@ -479,6 +564,20 @@
|
|||||||
<label class="fl"><%= t('admin.set.admin_key','Admin API Key (Untuk akses luar)') %></label>
|
<label class="fl"><%= t('admin.set.admin_key','Admin API Key (Untuk akses luar)') %></label>
|
||||||
<input class="fc" name="admin_api_key" value="<%= settings.admin_api_key || '' %>">
|
<input class="fc" name="admin_api_key" value="<%= settings.admin_api_key || '' %>">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Auto-Block Brute Force MikroTik</label>
|
||||||
|
<select class="fs" name="security_auto_block_enabled">
|
||||||
|
<option value="false" <%= String(settings.security_auto_block_enabled) !== 'true' ? 'selected' : '' %>>Nonaktif</option>
|
||||||
|
<option value="true" <%= String(settings.security_auto_block_enabled) === 'true' ? 'selected' : '' %>>Aktif (blokir IP otomatis)</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size:10px;color:var(--muted);margin-top:4px">Jika aktif, IP dengan percobaan login gagal berulang dimasukkan ke firewall blacklist 1 hari.</div>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Ambang Percobaan</label>
|
||||||
|
<input class="fc" type="number" min="2" max="50" name="security_mikrotik_threshold" value="<%= settings.security_mikrotik_threshold || 5 %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fitur Opsional -->
|
<!-- Fitur Opsional -->
|
||||||
@@ -640,9 +739,18 @@
|
|||||||
</script>
|
</script>
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label class="fl"><%= t('admin.set.repo_link','Link Repositori Proyek (Gitea)') %></label>
|
<label class="fl"><%= t('admin.set.repo_link','Link Repositori Proyek (Gitea)') %></label>
|
||||||
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://gitea.oxpoe.qzz.io/oxpoe/open-isp' %>" placeholder="https://gitea.oxpoe.qzz.io/oxpoe/open-isp">
|
<input class="fc" name="project_url" value="<%= settings.project_url || 'https://gitea.oxpoe.xyz/oxpoe/open-isp' %>" placeholder="https://gitea.oxpoe.xyz/oxpoe/open-isp">
|
||||||
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.repo_hint','Dipakai untuk tautan ') %><b>Repo</b>, <b>Form/Issue</b> (<code>/issues/new/choose</code>), <b>Wiki</b>, dan <b>Lisensi</b> di landing page.</div>
|
<div style="font-size:10px; color:var(--muted); margin-top:4px"><%= t('admin.set.repo_hint','Dipakai untuk tautan ') %><b>Repo</b>, <b>Form/Issue</b> (<code>/issues/new/choose</code>), <b>Wiki</b>, dan <b>Lisensi</b> di landing page.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Sumber Update (host Git)</label>
|
||||||
|
<input class="fc" name="update_base_url" value="<%= settings.update_base_url || 'https://gitea.oxpoe.xyz' %>" placeholder="https://gitea.oxpoe.xyz">
|
||||||
|
<div style="font-size:10px; color:var(--muted); margin-top:4px">Dipakai menu <b>Update</b> untuk <code>git fetch</code>. Bisa Gitea (<code>https://gitea.oxpoe.xyz</code>) atau Forgejo (<code>https://forgejo.oxpoe.xyz</code>).</div>
|
||||||
|
</div>
|
||||||
|
<div class="fg">
|
||||||
|
<label class="fl">Repo Update (owner/nama)</label>
|
||||||
|
<input class="fc" name="update_repo" value="<%= settings.update_repo || 'oxpoe/open-isp' %>" placeholder="oxpoe/open-isp">
|
||||||
|
</div>
|
||||||
<div class="fg">
|
<div class="fg">
|
||||||
<label class="fl"><%= t('admin.set.uptime_link','Link Uptime Website (Status)') %></label>
|
<label class="fl"><%= t('admin.set.uptime_link','Link Uptime Website (Status)') %></label>
|
||||||
<input class="fc" name="uptime_url" value="<%= settings.uptime_url || '' %>" placeholder="https://status.domain-anda.com">
|
<input class="fc" name="uptime_url" value="<%= settings.uptime_url || '' %>" placeholder="https://status.domain-anda.com">
|
||||||
@@ -796,7 +904,7 @@
|
|||||||
<div style="margin-top: 15px; border-top: 1px dashed rgba(0,0,0,0.1); padding-top: 15px;">
|
<div style="margin-top: 15px; border-top: 1px dashed rgba(0,0,0,0.1); padding-top: 15px;">
|
||||||
<h6 style="font-size:11px; margin-bottom: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-phone"></i> Hubungkan Webhook Notifikasi E-Wallet (Mode 2)</h6>
|
<h6 style="font-size:11px; margin-bottom: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-phone"></i> Hubungkan Webhook Notifikasi E-Wallet (Mode 2)</h6>
|
||||||
<%
|
<%
|
||||||
const _base = (settings.public_base_url || '').replace(/\/+$/, '');
|
const _base = String(typeof webhookBaseUrl !== 'undefined' && webhookBaseUrl ? webhookBaseUrl : (settings.public_base_url || '')).replace(/\/+$/, '');
|
||||||
const _secret = String(settings.webhook_secret || '');
|
const _secret = String(settings.webhook_secret || '');
|
||||||
const macroWebhookUrlFull = _base + '/api/webhook/v1/payment-notif?secret_key=' + encodeURIComponent(_secret);
|
const macroWebhookUrlFull = _base + '/api/webhook/v1/payment-notif?secret_key=' + encodeURIComponent(_secret);
|
||||||
%>
|
%>
|
||||||
@@ -822,6 +930,7 @@
|
|||||||
<option value="false" <%= (settings.webhook_unmatched_notify_enabled === undefined ? true : settings.webhook_unmatched_notify_enabled) ? '' : 'selected' %>><%= t('common.inactive','Nonaktif') %></option>
|
<option value="false" <%= (settings.webhook_unmatched_notify_enabled === undefined ? true : settings.webhook_unmatched_notify_enabled) ? '' : 'selected' %>><%= t('common.inactive','Nonaktif') %></option>
|
||||||
</select>
|
</select>
|
||||||
<div class="hint"><%= t('admin.set.wh_wa_hint','Kirim WA ke admin tiap ada pembayaran terdeteksi tapi belum cocok (untuk dicocokkan manual via jam notif vs bukti transfer).') %></div>
|
<div class="hint"><%= t('admin.set.wh_wa_hint','Kirim WA ke admin tiap ada pembayaran terdeteksi tapi belum cocok (untuk dicocokkan manual via jam notif vs bukti transfer).') %></div>
|
||||||
|
<div class="hint" style="margin-top:6px"><i class="bi bi-info-circle"></i> Secret dibuat otomatis jika kosong. URL memakai <b>Public Base URL</b>; bila kosong memakai host saat ini. Salin URL + token ke aplikasi notification forwarder.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fg" style="margin-top:10px">
|
<div class="fg" style="margin-top:10px">
|
||||||
<label class="fl"><i class="bi bi-clock-history"></i> <%= t('admin.set.wh_retention','Retensi Notifikasi Webhook (Otomasi)') %></label>
|
<label class="fl"><i class="bi bi-clock-history"></i> <%= t('admin.set.wh_retention','Retensi Notifikasi Webhook (Otomasi)') %></label>
|
||||||
@@ -1237,6 +1346,46 @@ function setSearch(q) {
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function testAiApi(btn) {
|
||||||
|
var st = document.getElementById('ai-test-status');
|
||||||
|
var provider = (document.querySelector('select[name="ai_provider"]') || {}).value || 'deepseek';
|
||||||
|
var map = {
|
||||||
|
deepseek: { key: 'deepseek_api_key', model: 'deepseek_model', base: '' },
|
||||||
|
openai: { key: 'openai_api_key', model: 'openai_model', base: '' },
|
||||||
|
groq: { key: 'groq_api_key', model: 'groq_model', base: '' },
|
||||||
|
openrouter: { key: 'openrouter_api_key', model: 'openrouter_model', base: '' },
|
||||||
|
custom: { key: 'custom_ai_api_key', model: 'custom_ai_model', base: 'custom_ai_base_url' }
|
||||||
|
};
|
||||||
|
var m = map[provider] || map.deepseek;
|
||||||
|
function val(name) { var el = document.querySelector('[name="' + name + '"]'); return el ? el.value : ''; }
|
||||||
|
var old = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
|
||||||
|
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Menguji ' + provider + '...'; }
|
||||||
|
try {
|
||||||
|
var r = await fetch('/admin/settings/test-ai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
provider: provider,
|
||||||
|
apiKey: val(m.key),
|
||||||
|
model: val(m.model),
|
||||||
|
baseUrl: m.base ? val(m.base) : ''
|
||||||
|
})
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (st) {
|
||||||
|
st.style.color = d.ok ? 'var(--success)' : 'var(--danger)';
|
||||||
|
st.textContent = (d.ok ? '✅ ' : '❌ ') + (d.message || (d.ok ? 'API valid.' : 'API tidak valid.'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (st) { st.style.color = 'var(--danger)'; st.textContent = '❌ Gagal menguji: ' + e.message; }
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = old; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
async function selfHeal(mode) {
|
async function selfHeal(mode) {
|
||||||
const out = document.getElementById('selfheal-out');
|
const out = document.getElementById('selfheal-out');
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-bd">
|
<div class="card-bd">
|
||||||
<p class="hint" style="margin-top:0">
|
<p class="hint" style="margin-top:0">
|
||||||
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil, Sembunyikan, atau Kunci. Menu yang dikunci akan meminta password khusus sebelum dibuka.') %>
|
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil atau Sembunyikan.') %>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<% if (canManageSidebar) { %>
|
<% if (canManageSidebar) { %>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
<select class="fs" name="menu_state[<%= menu.key %>]">
|
<select class="fs" name="menu_state[<%= menu.key %>]">
|
||||||
<option value="visible" <%= menu.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
|
<option value="visible" <%= menu.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
|
||||||
<option value="hidden" <%= menu.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
|
<option value="hidden" <%= menu.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
|
||||||
<option value="locked" <%= menu.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
<select class="fs" name="menu_state[<%= ch.key %>]">
|
<select class="fs" name="menu_state[<%= ch.key %>]">
|
||||||
<option value="visible" <%= ch.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
|
<option value="visible" <%= ch.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
|
||||||
<option value="hidden" <%= ch.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
|
<option value="hidden" <%= ch.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
|
||||||
<option value="locked" <%= ch.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -108,12 +108,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sidebar-settings-save-grid" style="margin-top:16px">
|
<div class="sidebar-settings-save-grid" style="margin-top:16px">
|
||||||
<div class="fg" style="margin:0">
|
<div class="fg" style="margin:0">
|
||||||
<label class="fl"><%= t('admin.sb.pass_label', 'Password Aktivasi (Seikhlasnya)') %></label>
|
|
||||||
<input type="password" class="fc" name="feature_password" placeholder="<%= t('admin.sb.pass_ph', 'Masukkan password aktivasi sebelum menyimpan') %>" required>
|
|
||||||
<div class="hint" style="margin-top:6px"><%= t('admin.sb.pass_hint', 'Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.') %></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hint" style="align-self:center">
|
<div class="hint" style="align-self:center">
|
||||||
<%= t('admin.sb.no_pass', 'Belum punya kode aktivasi?') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP') %></a> <%= t('admin.sb.or_contact', 'atau hubungi') %> <strong><%= featureContactPhone %></strong>.
|
<%= t('admin.sb.no_pass', 'Menu bisa ditampilkan/disembunyikan dari halaman ini.') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dukung pengembangan OPEN-ISP') %></a>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;flex-wrap:wrap">
|
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;flex-wrap:wrap">
|
||||||
@@ -138,12 +135,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-bd">
|
<div class="card-bd">
|
||||||
<div class="alert alert-s" style="margin-bottom:12px">
|
<div class="alert alert-s" style="margin-bottom:12px">
|
||||||
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi') %> <strong><%= featureContactPhone %></strong>.
|
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Menu berstatus Kunci tidak dapat dibuka sampai diaktifkan dari halaman ini.') %>
|
||||||
</div>
|
</div>
|
||||||
<ul style="margin:0;padding-left:18px;display:grid;gap:8px">
|
<ul style="margin:0;padding-left:18px;display:grid;gap:8px">
|
||||||
<li><%= t('admin.sb.note1', 'Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.') %></li>
|
<li><%= t('admin.sb.note1', 'Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.') %></li>
|
||||||
<li><%= t('admin.sb.note2', 'Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.') %></li>
|
<li><%= t('admin.sb.note2', 'Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.') %></li>
|
||||||
<li><%= t('admin.sb.note3', 'Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar.') %></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof restartPending !== 'undefined' && restartPending) { %>
|
||||||
|
<div class="alert alert-d" style="display:flex;gap:10px;align-items:flex-start">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill" style="font-size:18px;margin-top:1px"></i>
|
||||||
|
<div>
|
||||||
|
<b>Restart diperlukan.</b>
|
||||||
|
Update ke versi <b><%= restartPending.version || '-' %></b> sudah diterapkan ke file,
|
||||||
|
tetapi aplikasi masih menjalankan kode lama. Fitur/fix baru belum aktif sampai aplikasi di-restart.
|
||||||
|
<a href="#restart-help" style="color:inherit;text-decoration:underline;font-weight:700">Lihat cara restart</a>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<div class="card mb16">
|
<div class="card mb16">
|
||||||
<div class="card-hd"><h6><i class="bi bi-git"></i> <%= t('admin.update.status_title', 'Status Versi') %></h6></div>
|
<div class="card-hd"><h6><i class="bi bi-git"></i> <%= t('admin.update.status_title', 'Status Versi') %></h6></div>
|
||||||
<div class="card-bd">
|
<div class="card-bd">
|
||||||
@@ -79,6 +91,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb16" id="restart-help">
|
||||||
|
<div class="card-hd"><h6><i class="bi bi-arrow-repeat"></i> <%= t('admin.update.restart_title', 'Cara Restart Aplikasi') %></h6></div>
|
||||||
|
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px;font-size:12px">
|
||||||
|
<div style="border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:rgba(255,255,255,.02);line-height:1.75">
|
||||||
|
<div style="font-weight:800;margin-bottom:8px"><i class="bi bi-server"></i> <%= t('admin.update.restart_selfhosted', 'Self-hosted') %></div>
|
||||||
|
<div><b>PM2</b><br><code>pm2 restart all</code> <span style="color:var(--muted)">(atau <code>pm2 restart open-isp</code>)</span></div>
|
||||||
|
<div style="margin-top:8px"><b>Manual (tanpa PM2)</b><br><code>pkill -f "node app-customer.js"</code><br><code>nohup node app-customer.js > logs/run.log 2>&1 &</code></div>
|
||||||
|
<div style="margin-top:8px"><b>Quadlet / systemd</b><br><code>systemctl --user restart open-isp</code> <span style="color:var(--muted)">(user)</span><br><code>sudo systemctl restart open-isp</code> <span style="color:var(--muted)">(system)</span></div>
|
||||||
|
<div style="margin-top:8px"><b>Docker / Podman Compose</b><br><code>docker compose restart</code><br><code>podman compose restart</code> <span style="color:var(--muted)">(atau <code>up -d</code> bila env berubah)</span></div>
|
||||||
|
<div style="margin-top:8px;color:var(--muted)">Jalankan dari folder aplikasi, lalu muat ulang halaman ini.</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--border);border-radius:12px;padding:12px 14px;background:rgba(59,130,246,.06);line-height:1.75">
|
||||||
|
<div style="font-weight:800;margin-bottom:8px"><i class="bi bi-cloud"></i> <%= t('admin.update.restart_cloud', 'Cloud / managed') %></div>
|
||||||
|
<div><i class="bi bi-check2-circle" style="color:var(--success)"></i> Restart dilakukan <b>otomatis pada update berikutnya</b>.</div>
|
||||||
|
<div style="margin-top:8px"><i class="bi bi-headset"></i> Butuh restart sekarang? <b>Hubungi admin server</b> — jangan jalankan perintah restart sendiri.</div>
|
||||||
|
<div style="margin-top:8px;color:var(--muted)">Cocok untuk instance yang dikelola penyedia/hosting bersama.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% if (log) { %>
|
<% if (log) { %>
|
||||||
<div class="card mb16">
|
<div class="card mb16">
|
||||||
<div class="card-hd"><h6><i class="bi bi-terminal"></i> <%= t('admin.update.log_title', 'Log Update') %></h6></div>
|
<div class="card-hd"><h6><i class="bi bi-terminal"></i> <%= t('admin.update.log_title', 'Log Update') %></h6></div>
|
||||||
|
|||||||
@@ -154,6 +154,25 @@
|
|||||||
<!-- 3. GOWA (go-whatsapp-web-multidevice) FIELDS -->
|
<!-- 3. GOWA (go-whatsapp-web-multidevice) FIELDS -->
|
||||||
<div id="gowa_fields_container" style="<%= waGatewayType === 'gowa' ? '' : 'display:none;' %> background: rgba(173,255,47,0.05); padding: 16px; border-radius: 10px; border: 1px solid var(--primary-glow); margin-bottom: 16px;">
|
<div id="gowa_fields_container" style="<%= waGatewayType === 'gowa' ? '' : 'display:none;' %> background: rgba(173,255,47,0.05); padding: 16px; border-radius: 10px; border: 1px solid var(--primary-glow); margin-bottom: 16px;">
|
||||||
<div class="fw6 mb12" style="color: var(--primary); font-size: 13px;"><i class="bi bi-lightning-charge-fill"></i> Konfigurasi GOWA (go-whatsapp-web-multidevice) — Ringan / Self-Hosted</div>
|
<div class="fw6 mb12" style="color: var(--primary); font-size: 13px;"><i class="bi bi-lightning-charge-fill"></i> Konfigurasi GOWA (go-whatsapp-web-multidevice) — Ringan / Self-Hosted</div>
|
||||||
|
<div class="form-row mb12">
|
||||||
|
<div class="fg" style="flex:1;">
|
||||||
|
<label class="fl">Mode Akses GOWA</label>
|
||||||
|
<select class="fs" name="gowa_mode" id="gowa_mode" onchange="toggleGowaMode()">
|
||||||
|
<option value="cloud" <%= gowaSettings.gowa_mode === 'local' ? '' : 'selected' %>>Cloud (HTTPS) — disarankan</option>
|
||||||
|
<option value="local" <%= gowaSettings.gowa_mode === 'local' ? 'selected' : '' %>>Local (HTTP) — wajib polyfill</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="fg" style="flex:2;">
|
||||||
|
<label class="fl">Keterangan</label>
|
||||||
|
<div id="gowa_mode_hint" style="font-size:11.5px;color:var(--muted);line-height:1.5">
|
||||||
|
<% if (gowaSettings.gowa_mode === 'local') { %>
|
||||||
|
Base URL <code>http://IP:3000</code>. UI GOWA wajib diberi polyfill <code>crypto.randomUUID</code> (atau akses lewat SSH tunnel). Panduan: <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki/Konfigurasi" target="_blank" rel="noopener" style="color:var(--primary)">Wiki → Konfigurasi → GOWA Local</a>.
|
||||||
|
<% } else { %>
|
||||||
|
Base URL <code>https://domain-gowa</code> dengan TLS. UI GOWA langsung normal, tanpa polyfill.
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-row mb12">
|
<div class="form-row mb12">
|
||||||
<div class="fg" style="flex:2;">
|
<div class="fg" style="flex:2;">
|
||||||
<label class="fl">Base URL GOWA *</label>
|
<label class="fl">Base URL GOWA *</label>
|
||||||
@@ -162,7 +181,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="fg" style="flex:1;">
|
<div class="fg" style="flex:1;">
|
||||||
<label class="fl"><%= t('admin.wa.ph_deviceid','Device ID (opsional)') %></label>
|
<label class="fl"><%= t('admin.wa.ph_deviceid','Device ID (opsional)') %></label>
|
||||||
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="multi-device (mis. device1)">
|
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="kosongkan = ikuti device yang terhubung">
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">Kosongkan untuk otomatis mengikuti device yang login via QR. Isi hanya bila ingin menunjuk device GOWA tertentu.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row mb12">
|
<div class="form-row mb12">
|
||||||
@@ -180,8 +200,10 @@
|
|||||||
<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px"><%- t('admin.wa.qr_hint','Klik tombol, lalu buka WhatsApp → Perangkat Tertaut → Tautkan perangkat, scan QR. QR berlaku ~30 detik & diperbarui otomatis.') %></div>
|
<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px"><%- t('admin.wa.qr_hint','Klik tombol, lalu buka WhatsApp → Perangkat Tertaut → Tautkan perangkat, scan QR. QR berlaku ~30 detik & diperbarui otomatis.') %></div>
|
||||||
<button type="button" class="btn btn-p btn-sm" onclick="showGowaQr()"><i class="bi bi-qr-code-scan"></i> Tampilkan QR</button>
|
<button type="button" class="btn btn-p btn-sm" onclick="showGowaQr()"><i class="bi bi-qr-code-scan"></i> Tampilkan QR</button>
|
||||||
<a href="<%= gowaUiUrl %>" target="_blank" rel="noopener" class="btn btn-g btn-sm" style="margin-left:6px"><i class="bi bi-box-arrow-up-right"></i> Buka UI GOWA</a>
|
<a href="<%= gowaUiUrl %>" target="_blank" rel="noopener" class="btn btn-g btn-sm" style="margin-left:6px"><i class="bi bi-box-arrow-up-right"></i> Buka UI GOWA</a>
|
||||||
|
<button type="button" class="btn btn-g btn-sm" style="margin-left:6px" onclick="testGowaConn(this)"><i class="bi bi-plug"></i> Test Koneksi</button>
|
||||||
|
<div id="gowa_conn_status" style="display:none;margin-top:8px;font-size:12px;line-height:1.5"></div>
|
||||||
<div id="gowa_qr_wrap" style="display:none;margin-top:10px;text-align:center">
|
<div id="gowa_qr_wrap" style="display:none;margin-top:10px;text-align:center">
|
||||||
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
|
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px" onerror="var s=document.getElementById('gowa_qr_status');if(s)s.textContent='Gambar QR gagal dimuat — cek log server (base URL/kredensial GOWA).'">
|
||||||
<div id="gowa_qr_status" style="font-size:12px;color:var(--muted);margin-top:6px"></div>
|
<div id="gowa_qr_status" style="font-size:12px;color:var(--muted);margin-top:6px"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -197,8 +219,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fg" style="flex:1;">
|
<div class="fg" style="flex:1;">
|
||||||
|
<label class="fl">Mode Webhook Secret</label>
|
||||||
|
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||||||
|
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'auto' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-auto" onclick="gowaSecretMode('auto')"><i class="bi bi-magic"></i> Auto Generate</button>
|
||||||
|
<button type="button" class="btn btn-sm <%= gowaSettings.gowa_secret_mode === 'manual' ? 'btn-p' : 'btn-g' %>" id="gsm-btn-manual" onclick="gowaSecretMode('manual')"><i class="bi bi-pencil-square"></i> Manual</button>
|
||||||
|
</div>
|
||||||
<label class="fl">Webhook Secret (HMAC)</label>
|
<label class="fl">Webhook Secret (HMAC)</label>
|
||||||
<input class="fc" name="gowa_webhook_secret" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="secret">
|
<div style="display:flex;gap:8px;">
|
||||||
|
<input class="fc" id="gowa_webhook_secret" name="gowa_webhook_secret" data-mode="<%= gowaSettings.gowa_secret_mode %>" data-locked="1" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="otomatis dibuat">
|
||||||
|
<button type="button" class="btn btn-g" onclick="copyGowaSecret(this)" title="Salin webhook secret"><i class="bi bi-copy"></i></button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
|
||||||
|
<button type="button" class="btn btn-p btn-sm" id="gsm-btn-save" onclick="gowaSecretSave(this)"><i class="bi bi-save"></i> Simpan Secret</button>
|
||||||
|
<button type="button" class="btn btn-g btn-sm" id="gsm-btn-edit" onclick="gowaSecretEdit()"><i class="bi bi-unlock"></i> Ubah</button>
|
||||||
|
</div>
|
||||||
|
<div id="gowa_secret_status" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px">Auto Generate: secret dibuat & dikunci otomatis. Manual: isi sendiri lalu klik Simpan Secret. Samakan dengan <code>WHATSAPP_WEBHOOK_SECRET</code> di GOWA.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,6 +254,47 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
async function testGowaConn(btn) {
|
||||||
|
var el = document.getElementById('gowa_conn_status');
|
||||||
|
var old = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menguji...'; }
|
||||||
|
if (el) { el.style.display = 'block'; el.style.color = 'var(--muted)'; el.textContent = 'Menghubungi ' + (document.querySelector('input[name="gowa_base_url"]') || {}).value + ' ...'; }
|
||||||
|
try {
|
||||||
|
var payload = {
|
||||||
|
gowa_base_url: (document.querySelector('input[name="gowa_base_url"]') || {}).value || '',
|
||||||
|
gowa_username: (document.querySelector('input[name="gowa_username"]') || {}).value || '',
|
||||||
|
gowa_password: (document.querySelector('input[name="gowa_password"]') || {}).value || ''
|
||||||
|
};
|
||||||
|
var r = await fetch('/admin/whatsapp/test-gowa-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (el) {
|
||||||
|
el.textContent = d.message || (d.ok ? 'GOWA terjangkau.' : 'GOWA tidak terjangkau.');
|
||||||
|
el.style.color = (d.ok && d.authOk) ? 'var(--success)' : (d.ok ? '#f59e0b' : 'var(--danger)');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (el) { el.style.color = 'var(--danger)'; el.textContent = 'Gagal menguji koneksi: ' + e.message; }
|
||||||
|
} finally {
|
||||||
|
if (btn) { btn.disabled = false; btn.innerHTML = old; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleGowaMode() {
|
||||||
|
var mode = (document.getElementById('gowa_mode') || {}).value || 'cloud';
|
||||||
|
var hint = document.getElementById('gowa_mode_hint');
|
||||||
|
var base = document.querySelector('input[name="gowa_base_url"]');
|
||||||
|
if (mode === 'local') {
|
||||||
|
if (hint) hint.innerHTML = 'Base URL <code>http://IP:3000</code>. UI GOWA wajib diberi polyfill <code>crypto.randomUUID</code> (atau akses lewat SSH tunnel). Panduan: <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki/Konfigurasi" target="_blank" rel="noopener" style="color:var(--primary)">Wiki → Konfigurasi → GOWA Local</a>.';
|
||||||
|
if (base) base.placeholder = 'http://IP:3000';
|
||||||
|
} else {
|
||||||
|
if (hint) hint.innerHTML = 'Base URL <code>https://domain-gowa</code> dengan TLS. UI GOWA langsung normal, tanpa polyfill.';
|
||||||
|
if (base) base.placeholder = 'https://gowa.domainmu.com';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleGatewayFields() {
|
function toggleGatewayFields() {
|
||||||
const val = document.getElementById('wa_gateway_type').value;
|
const val = document.getElementById('wa_gateway_type').value;
|
||||||
document.getElementById('fonnte_fields_container').style.display = val === 'fonnte' ? 'block' : 'none';
|
document.getElementById('fonnte_fields_container').style.display = val === 'fonnte' ? 'block' : 'none';
|
||||||
@@ -228,12 +305,38 @@
|
|||||||
// Cooldown QR disinkronkan dengan GoWA: interval mengikuti qr_duration dari server.
|
// Cooldown QR disinkronkan dengan GoWA: interval mengikuti qr_duration dari server.
|
||||||
var _gowaQrRefresh = null; // penjadwal ambil QR berikutnya (timeout)
|
var _gowaQrRefresh = null; // penjadwal ambil QR berikutnya (timeout)
|
||||||
var _gowaQrTick = null; // interval hitung mundur 1 detik
|
var _gowaQrTick = null; // interval hitung mundur 1 detik
|
||||||
|
var _gowaQrPoll = null; // pantau status setelah scan
|
||||||
function showGowaQr() {
|
function showGowaQr() {
|
||||||
var wrap = document.getElementById('gowa_qr_wrap');
|
var wrap = document.getElementById('gowa_qr_wrap');
|
||||||
if (wrap) wrap.style.display = 'block';
|
if (wrap) wrap.style.display = 'block';
|
||||||
var st = document.getElementById('gowa_qr_status');
|
var st = document.getElementById('gowa_qr_status');
|
||||||
if (st) st.textContent = 'Memuat QR...';
|
if (st) { st.style.color = 'var(--muted)'; st.textContent = 'Memuat QR...'; }
|
||||||
gowaQrCycle();
|
gowaQrCycle();
|
||||||
|
startGowaPoll();
|
||||||
|
}
|
||||||
|
function stopGowaPoll() {
|
||||||
|
if (_gowaQrPoll) { clearInterval(_gowaQrPoll); _gowaQrPoll = null; }
|
||||||
|
}
|
||||||
|
function startGowaPoll() {
|
||||||
|
stopGowaPoll();
|
||||||
|
_gowaQrPoll = setInterval(async function () {
|
||||||
|
try {
|
||||||
|
var r = await fetch('/admin/api/whatsapp/status', { cache: 'no-store' });
|
||||||
|
var s = await r.json();
|
||||||
|
var st = document.getElementById('gowa_qr_status');
|
||||||
|
if (s && s.connection === 'open') {
|
||||||
|
if (_gowaQrRefresh) { clearTimeout(_gowaQrRefresh); _gowaQrRefresh = null; }
|
||||||
|
if (_gowaQrTick) { clearInterval(_gowaQrTick); _gowaQrTick = null; }
|
||||||
|
stopGowaPoll();
|
||||||
|
var img = document.getElementById('gowa_qr_img'); if (img) img.src = '';
|
||||||
|
var wrap = document.getElementById('gowa_qr_wrap'); if (wrap) wrap.style.display = 'none';
|
||||||
|
if (st) { st.style.color = 'var(--success)'; st.textContent = '✅ Terhubung ke WhatsApp.'; }
|
||||||
|
if (typeof checkStatus === 'function') checkStatus();
|
||||||
|
} else if (s && s.connection === 'connecting') {
|
||||||
|
if (st) { st.style.color = 'var(--warning, #f59e0b)'; st.textContent = 'Terautentikasi, sedang menyinkronkan...'; }
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
function gowaQrCycle() {
|
function gowaQrCycle() {
|
||||||
fetch('/admin/whatsapp/gowa/qr', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
|
fetch('/admin/whatsapp/gowa/qr', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
|
||||||
@@ -257,6 +360,88 @@
|
|||||||
render();
|
render();
|
||||||
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
|
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
|
||||||
}
|
}
|
||||||
|
function gowaSecretRender() {
|
||||||
|
var input = document.getElementById('gowa_webhook_secret');
|
||||||
|
if (!input) return;
|
||||||
|
var mode = input.getAttribute('data-mode') || 'auto';
|
||||||
|
var locked = (mode === 'auto') ? true : (input.getAttribute('data-locked') === '1');
|
||||||
|
var save = document.getElementById('gsm-btn-save');
|
||||||
|
var edit = document.getElementById('gsm-btn-edit');
|
||||||
|
var auto = document.getElementById('gsm-btn-auto');
|
||||||
|
var manual = document.getElementById('gsm-btn-manual');
|
||||||
|
var st = document.getElementById('gowa_secret_status');
|
||||||
|
input.readOnly = locked;
|
||||||
|
if (save) save.disabled = (mode === 'auto') || locked;
|
||||||
|
if (edit) edit.style.display = (mode === 'manual' && locked) ? 'inline-block' : 'none';
|
||||||
|
if (auto) auto.className = 'btn btn-sm ' + (mode === 'auto' ? 'btn-p' : 'btn-g');
|
||||||
|
if (manual) manual.className = 'btn btn-sm ' + (mode === 'manual' ? 'btn-p' : 'btn-g');
|
||||||
|
if (st) {
|
||||||
|
if (mode === 'auto') st.textContent = 'Mode otomatis: secret dibuat dan dikunci. Salin ke GOWA.';
|
||||||
|
else if (locked) st.textContent = 'Mode manual: secret tersimpan dan dikunci. Klik Ubah untuk mengganti.';
|
||||||
|
else st.textContent = 'Mode manual: masukkan secret (min. 16 karakter), lalu Simpan Secret.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function gowaSecretMode(mode) {
|
||||||
|
var input = document.getElementById('gowa_webhook_secret');
|
||||||
|
if (!input) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/admin/whatsapp/gowa-secret', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: mode })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (!d.ok) { alert(d.message || 'Gagal mengubah mode secret.'); return; }
|
||||||
|
if (d.secret) input.value = d.secret;
|
||||||
|
input.setAttribute('data-mode', d.mode);
|
||||||
|
input.setAttribute('data-locked', d.mode === 'manual' ? '0' : '1');
|
||||||
|
gowaSecretRender();
|
||||||
|
} catch (e) { alert('Gagal: ' + e.message); }
|
||||||
|
}
|
||||||
|
async function gowaSecretSave(btn) {
|
||||||
|
var input = document.getElementById('gowa_webhook_secret');
|
||||||
|
if (!input) return;
|
||||||
|
var secret = String(input.value || '').trim();
|
||||||
|
if (secret.length < 16) { alert('Secret minimal 16 karakter.'); return; }
|
||||||
|
var old = btn ? btn.innerHTML : '';
|
||||||
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menyimpan...'; }
|
||||||
|
try {
|
||||||
|
var r = await fetch('/admin/whatsapp/gowa-secret', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: 'manual', secret: secret })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (!d.ok) { alert(d.message || 'Gagal menyimpan secret.'); return; }
|
||||||
|
input.value = d.secret || secret;
|
||||||
|
input.setAttribute('data-mode', 'manual');
|
||||||
|
input.setAttribute('data-locked', '1');
|
||||||
|
gowaSecretRender();
|
||||||
|
} catch (e) { alert('Gagal: ' + e.message); }
|
||||||
|
finally { if (btn) { btn.disabled = false; btn.innerHTML = old; } gowaSecretRender(); }
|
||||||
|
}
|
||||||
|
function gowaSecretEdit() {
|
||||||
|
var input = document.getElementById('gowa_webhook_secret');
|
||||||
|
if (!input || input.getAttribute('data-mode') !== 'manual') return;
|
||||||
|
input.setAttribute('data-locked', '0');
|
||||||
|
gowaSecretRender();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', gowaSecretRender);
|
||||||
|
|
||||||
|
function copyGowaSecret(btn) {
|
||||||
|
var el = document.getElementById('gowa_webhook_secret');
|
||||||
|
if (!el) return;
|
||||||
|
var val = String(el.value || '');
|
||||||
|
try { el.select(); document.execCommand('copy'); } catch (e) {}
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
navigator.clipboard.writeText(val).catch(function () {});
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
var old = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="bi bi-check2"></i>';
|
||||||
|
setTimeout(function () { btn.innerHTML = old; }, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyGowaWebhook() {
|
function copyGowaWebhook() {
|
||||||
var el = document.getElementById('gowa_webhook_url');
|
var el = document.getElementById('gowa_webhook_url');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
<h1 style="font-size:22px"><%= t('donate.title','Dukung OPEN-ISP') %></h1>
|
<h1 style="font-size:22px"><%= t('donate.title','Dukung OPEN-ISP') %></h1>
|
||||||
<p class="iso-sub"><%= settings.company_header || 'OPEN-ISP' %> • <%= t('donate.tagline','Donasi sukarela untuk pengembangan aplikasi open source (ISC)') %></p>
|
<p class="iso-sub"><%= settings.company_header || 'OPEN-ISP' %> • <%= t('donate.tagline','Donasi sukarela untuk pengembangan aplikasi open source (ISC)') %></p>
|
||||||
<div style="margin-top:14px;padding:12px 14px;border-radius:14px;background:rgba(173,255,47,.08);border:1px solid rgba(173,255,47,.25);text-align:left;font-size:12.5px;color:#cfe9a0">
|
<div style="margin-top:14px;padding:12px 14px;border-radius:14px;background:rgba(173,255,47,.08);border:1px solid rgba(173,255,47,.25);text-align:left;font-size:12.5px;color:#cfe9a0">
|
||||||
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Kode aktivasi menu tersedia GRATIS') %></strong> <%= t('donate.free_hint','untuk semua — donasi') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
|
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Semua menu sudah terbuka') %></strong> <%= t('donate.free_hint','— donasi bersifat sukarela dan') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
|
||||||
<a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
|
<a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,10 +70,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp * (untuk kode aktivasi)') %></label>
|
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp') %></label>
|
||||||
<div class="wrap"><i class="bi bi-whatsapp lead-ic"></i>
|
<div class="wrap"><i class="bi bi-whatsapp lead-ic"></i>
|
||||||
<input type="tel" id="inputPhone" class="inp" placeholder="Contoh: 081234567890" required></div>
|
<input type="tel" id="inputPhone" class="inp" placeholder="Contoh: 081234567890" required></div>
|
||||||
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih & kode aktivasi sidebar dikirim ke nomor ini.') %></div>
|
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih dikirim ke nomor ini.') %></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<div id="paymentStatusBox" class="auth-alert info" style="justify-content:center;margin-top:14px">
|
<div id="paymentStatusBox" class="auth-alert info" style="justify-content:center;margin-top:14px">
|
||||||
<div style="text-align:center">
|
<div style="text-align:center">
|
||||||
<div style="display:flex;align-items:center;justify-content:center;gap:8px"><div class="spin-pulse"></div><span style="font-weight:700" id="statusBoxText"><%= t('donate.waiting_payment','Menunggu Pembayaran Masuk…') %></span></div>
|
<div style="display:flex;align-items:center;justify-content:center;gap:8px"><div class="spin-pulse"></div><span style="font-weight:700" id="statusBoxText"><%= t('donate.waiting_payment','Menunggu Pembayaran Masuk…') %></span></div>
|
||||||
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi & mengirim kode aktivasi ke WA Anda.') %></div>
|
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi pembayaran.') %></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
<span class="badge bp"><i class="bi bi-git"></i> <%= t('donate.code_free','KODE AKTIVASI GRATIS') %></span>
|
<span class="badge bp"><i class="bi bi-git"></i> <%= t('donate.code_free','KODE AKTIVASI GRATIS') %></span>
|
||||||
<span style="font-weight:700;font-size:14px"><%= t('donate.status','Status:') %> <span style="color:var(--warning);font-weight:800" id="statusText"><%= t('donate.waiting_donation','Menunggu Donasi Masuk') %></span></span>
|
<span style="font-weight:700;font-size:14px"><%= t('donate.status','Status:') %> <span style="color:var(--warning);font-weight:800" id="statusText"><%= t('donate.waiting_donation','Menunggu Donasi Masuk') %></span></span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://gitea.oxpoe.qzz.io/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
|
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://gitea.oxpoe.xyz/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AKTIVASI CARD -->
|
<!-- AKTIVASI CARD -->
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
async function generateQris() {
|
async function generateQris() {
|
||||||
if (isPaid) return;
|
if (isPaid) return;
|
||||||
var amt = parseInt(document.getElementById('inputAmount').value, 10) || 50000;
|
var amt = parseInt(document.getElementById('inputAmount').value, 10) || 50000;
|
||||||
var phone = document.getElementById('inputPhone').value.trim() || '081947215703';
|
var phone = document.getElementById('inputPhone').value.trim();
|
||||||
var name = document.getElementById('inputName').value.trim() || 'Hamba Allah';
|
var name = document.getElementById('inputName').value.trim() || 'Hamba Allah';
|
||||||
var notes = document.getElementById('inputNotes').value.trim();
|
var notes = document.getElementById('inputNotes').value.trim();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -315,7 +315,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<p class="muted" style="font-size:14px;max-width:40ch"><%= t('landing.footer.tagline','Micro Internet Provider dan Server Platform.') %></p>
|
<p class="muted" style="font-size:14px;max-width:40ch"><%= t('landing.footer.tagline','Micro Internet Provider dan Server Platform.') %></p>
|
||||||
<p class="muted" style="font-size:13px;margin-top:8px;max-width:40ch">Internet Freedom — We Are Open-Source.</p>
|
<p class="muted" style="font-size:13px;margin-top:8px;max-width:40ch">Internet Freedom — We Are Open-Source.</p>
|
||||||
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
|
<p class="muted" style="font-size:12.5px;margin-top:12px"><% if (typeof settings !== 'undefined' && settings && settings.company_subheader) { %><%= settings.company_subheader %><% } else { %>Made with <i class="bi bi-heart-fill" style="color:#e5484d"></i> from Oxpoe in INA.<% } %></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4><%= t('landing.footer.nav','Navigasi') %></h4>
|
<h4><%= t('landing.footer.nav','Navigasi') %></h4>
|
||||||
|
|||||||
@@ -31,6 +31,21 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<%
|
||||||
|
const _portal = (typeof settings !== 'undefined' && settings) ? settings : {};
|
||||||
|
const _pbColors = {
|
||||||
|
info: { bg: 'rgba(59,130,246,.16)', border: '#3b82f6', fg: '#bfdbfe' },
|
||||||
|
warning: { bg: 'rgba(245,158,11,.16)', border: '#f59e0b', fg: '#fde68a' },
|
||||||
|
danger: { bg: 'rgba(239,68,68,.16)', border: '#ef4444', fg: '#fecaca' },
|
||||||
|
success: { bg: 'rgba(34,197,94,.16)', border: '#22c55e', fg: '#bbf7d0' }
|
||||||
|
};
|
||||||
|
const _pb = _pbColors[_portal.portal_banner_type] || _pbColors.info;
|
||||||
|
%>
|
||||||
|
<% if (_portal.portal_banner_enabled && _portal.portal_banner_text) { %>
|
||||||
|
<div style="background:<%= _pb.bg %>;border-bottom:1px solid <%= _pb.border %>;color:<%= _pb.fg %>;padding:9px 16px;text-align:center;font-size:13px;line-height:1.45">
|
||||||
|
<i class="bi bi-megaphone-fill"></i> <strong><%= _portal.portal_banner_text %></strong>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<!-- ═══ HEADER ═══ -->
|
<!-- ═══ HEADER ═══ -->
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
@@ -69,9 +84,21 @@
|
|||||||
<section class="auth-hero">
|
<section class="auth-hero">
|
||||||
<div class="container auth-grid">
|
<div class="container auth-grid">
|
||||||
<div class="auth-copy reveal">
|
<div class="auth-copy reveal">
|
||||||
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= t('landing.footer.portal','Portal Pelanggan') %></span>
|
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= _portal.portal_hero_badge || t('landing.footer.portal','Portal Pelanggan') %></span>
|
||||||
|
<% if (_portal.portal_hero_title) { %>
|
||||||
|
<h1><%= _portal.portal_hero_title %></h1>
|
||||||
|
<% } else { %>
|
||||||
<h1><%= t('login.manage','Kelola layanan internet Anda') %> <span class="hl"><%= t('login.manage_hl','dalam satu tempat') %></span></h1>
|
<h1><%= t('login.manage','Kelola layanan internet Anda') %> <span class="hl"><%= t('login.manage_hl','dalam satu tempat') %></span></h1>
|
||||||
<p class="lead"><%= t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
|
<% } %>
|
||||||
|
<p class="lead"><%= _portal.portal_hero_subtitle || t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
|
||||||
|
<% if (_portal.portal_network_status) {
|
||||||
|
const _ns = { normal: ['#22c55e', 'Jaringan Normal'], maintenance: ['#f59e0b', 'Pemeliharaan Terjadwal'], issue: ['#ef4444', 'Sedang Ada Kendala Teknis'] }[_portal.portal_network_status] || ['#22c55e', 'Jaringan Normal'];
|
||||||
|
%>
|
||||||
|
<div style="display:inline-flex;align-items:center;gap:7px;margin-top:10px;padding:5px 11px;border-radius:999px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.12);font-size:12px;color:var(--muted)">
|
||||||
|
<span style="width:8px;height:8px;border-radius:50%;background:<%= _ns[0] %>;display:inline-block"></span>
|
||||||
|
<%= _portal.portal_network_status_text || _ns[1] %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
<ul class="auth-points">
|
<ul class="auth-points">
|
||||||
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.chk_desc','Cek & bayar tagihan kapan saja') %></li>
|
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.chk_desc','Cek & bayar tagihan kapan saja') %></li>
|
||||||
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.mon_desc','Pantau trafik dan status koneksi') %></li>
|
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.mon_desc','Pantau trafik dan status koneksi') %></li>
|
||||||
@@ -205,7 +232,15 @@
|
|||||||
</a>
|
</a>
|
||||||
<p class="muted" style="font-size:14px;max-width:40ch">Micro Internet Provider dan Server Platform.</p>
|
<p class="muted" style="font-size:14px;max-width:40ch">Micro Internet Provider dan Server Platform.</p>
|
||||||
<p class="muted" style="font-size:13px;margin-top:8px">Internet Freedom — We Are Open-Source.</p>
|
<p class="muted" style="font-size:13px;margin-top:8px">Internet Freedom — We Are Open-Source.</p>
|
||||||
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
|
<p class="muted" style="font-size:12.5px;margin-top:12px"><% if (typeof settings !== 'undefined' && settings && settings.company_subheader) { %><%= settings.company_subheader %><% } else { %>Made with <i class="bi bi-heart-fill" style="color:#e5484d"></i> from Oxpoe in INA.<% } %></p>
|
||||||
|
<% if (_portal.social_maps_url || _portal.social_instagram || _portal.social_facebook || _portal.social_tiktok) { %>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap;font-size:16px">
|
||||||
|
<% if (_portal.social_maps_url) { %><a href="<%= _portal.social_maps_url %>" target="_blank" rel="noopener" title="Maps"><i class="bi bi-geo-alt-fill"></i></a><% } %>
|
||||||
|
<% if (_portal.social_instagram) { %><a href="<%= _portal.social_instagram %>" target="_blank" rel="noopener" title="Instagram"><i class="bi bi-instagram"></i></a><% } %>
|
||||||
|
<% if (_portal.social_facebook) { %><a href="<%= _portal.social_facebook %>" target="_blank" rel="noopener" title="Facebook"><i class="bi bi-facebook"></i></a><% } %>
|
||||||
|
<% if (_portal.social_tiktok) { %><a href="<%= _portal.social_tiktok %>" target="_blank" rel="noopener" title="TikTok"><i class="bi bi-tiktok"></i></a><% } %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4>Portal</h4>
|
<h4>Portal</h4>
|
||||||
@@ -230,7 +265,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="foot-bottom">
|
<div class="foot-bottom">
|
||||||
<span>© <span id="year"></span> <%= settings.company_header || 'OPEN-ISP' %>. Seluruh hak cipta dilindungi. <span style="opacity:.6">· v<%= appVersion %> (<%= appVersionName %>)</span></span>
|
<span>© <span id="year"></span> <%= settings.company_header || 'OPEN-ISP' %>. Seluruh hak cipta dilindungi. <span style="opacity:.6">· v<%= appVersion %> (<%= appVersionName %>)</span></span>
|
||||||
<span class="mono">Fiber Optic Internet Service · <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> · <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
|
<span class="mono">tiawidianti & irpankomara · <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> · <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -936,7 +936,7 @@
|
|||||||
<i class="ph ph-user-circle nav-icon"></i><%= t('pwa.nav.account','Akun') %></button>
|
<i class="ph ph-user-circle nav-icon"></i><%= t('pwa.nav.account','Akun') %></button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="p-3 mt-auto" style="border-top:1px solid var(--border);">
|
<div class="p-3 mt-auto" style="border-top:1px solid var(--border);">
|
||||||
<div class="flex items-center gap-3 p-3 rounded-[14px] press cursor-pointer">
|
<button type="button" onclick="switchTab('profile')" class="flex items-center gap-3 p-3 rounded-[14px] press cursor-pointer w-full text-left" style="background:transparent;border:0;" aria-label="<%= t('pwa.nav.account','Akun') %>">
|
||||||
<div class="avatar-ring" style="width:38px;height:38px;padding:2px;">
|
<div class="avatar-ring" style="width:38px;height:38px;padding:2px;">
|
||||||
<span class="avatar-init" style="width:34px;height:34px;font-size:13px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
|
<span class="avatar-init" style="width:34px;height:34px;font-size:13px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -945,7 +945,7 @@
|
|||||||
<p class="text-[11px] truncate" style="color:var(--muted);"><%= portal.packageName %><%= portal.speedDown !== '-' ? ' · ' + portal.speedDown + ' Mbps' : '' %></p>
|
<p class="text-[11px] truncate" style="color:var(--muted);"><%= portal.packageName %><%= portal.speedDown !== '-' ? ' · ' + portal.speedDown + ' Mbps' : '' %></p>
|
||||||
</div>
|
</div>
|
||||||
<i class="ph ph-caret-right text-sm" style="color:var(--muted-2);"></i>
|
<i class="ph ph-caret-right text-sm" style="color:var(--muted-2);"></i>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -976,9 +976,9 @@
|
|||||||
<i class="ph-fill ph-bell text-lg" style="color:var(--text);"></i>
|
<i class="ph-fill ph-bell text-lg" style="color:var(--text);"></i>
|
||||||
<span class="notif-dot"<%= (portal.notifications && portal.notifications.length) ? '' : ' style="display:none"' %>></span>
|
<span class="notif-dot"<%= (portal.notifications && portal.notifications.length) ? '' : ' style="display:none"' %>></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="mobile-only md:hidden avatar-ring" style="padding:2px;cursor:pointer;" onclick="switchTab('profile')">
|
<button type="button" class="mobile-only md:hidden avatar-ring press" style="padding:2px;cursor:pointer;background:transparent;border:0;" onclick="switchTab('profile')" aria-label="<%= t('pwa.nav.account','Akun') %>">
|
||||||
<span class="avatar-init" style="width:32px;height:32px;font-size:12px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
|
<span class="avatar-init" style="width:32px;height:32px;font-size:12px;" role="img" aria-label="<%= portal.name %>"><%= portal.initials %></span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -412,15 +412,15 @@ document.getElementById('btn-submit-action').addEventListener('click', async fun
|
|||||||
if (type === 'ssid') {
|
if (type === 'ssid') {
|
||||||
const ssid = document.getElementById('new-ssid').value.trim();
|
const ssid = document.getElementById('new-ssid').value.trim();
|
||||||
if (!ssid) return alert('SSID tidak boleh kosong');
|
if (!ssid) return alert('SSID tidak boleh kosong');
|
||||||
url = `/tech/api/device/${tag}/ssid`;
|
url = `/tech/api/device/${encodeURIComponent(tag)}/ssid`;
|
||||||
body = { ssid };
|
body = { ssid };
|
||||||
} else if (type === 'pass') {
|
} else if (type === 'pass') {
|
||||||
const password = document.getElementById('new-password').value.trim();
|
const password = document.getElementById('new-password').value.trim();
|
||||||
if (password.length < 8) return alert('Password minimal 8 karakter');
|
if (password.length < 8) return alert('Password minimal 8 karakter');
|
||||||
url = `/tech/api/device/${tag}/password`;
|
url = `/tech/api/device/${encodeURIComponent(tag)}/password`;
|
||||||
body = { password };
|
body = { password };
|
||||||
} else if (type === 'reboot') {
|
} else if (type === 'reboot') {
|
||||||
url = `/tech/api/device/${tag}/reboot`;
|
url = `/tech/api/device/${encodeURIComponent(tag)}/reboot`;
|
||||||
body = {};
|
body = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||