memperbarui lupa password serta OTP pada email dan fitur setting telah dirubah dan disederhanakan, backend dan API
This commit is contained in:
parent
7502d80d4b
commit
cc37039155
|
|
@ -3,6 +3,8 @@
|
||||||
*.log
|
*.log
|
||||||
*.pyc
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.atom/
|
.atom/
|
||||||
.build/
|
.build/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
LEMBAR PENGUJIAN SISTEM (BLACKBOX TESTING)
|
||||||
|
APLIKASI MOBILE PREDIKSI PERMINTAAN STOK BAHAN KUE
|
||||||
|
TOBAKU SULASTRI
|
||||||
|
|
||||||
|
|
||||||
|
IDENTITAS PENGUJI
|
||||||
|
Nama Lengkap
|
||||||
|
: ............................................
|
||||||
|
Jabatan
|
||||||
|
: ............................................
|
||||||
|
Tanggal Pengisian
|
||||||
|
: ............................................
|
||||||
|
|
||||||
|
TABEL SKENARIO PENGUJIAN
|
||||||
|
(Berikan tanda centang (√) pada kolom Status sesuai hasil pengujian yang Anda lakukan)
|
||||||
|
No
|
||||||
|
Fitur
|
||||||
|
Skenario Pengujian
|
||||||
|
Hasil yang Diharapkan
|
||||||
|
Status
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Berhasil
|
||||||
|
Tidak
|
||||||
|
A. Autentikasi Pengguna
|
||||||
|
1
|
||||||
|
Login
|
||||||
|
Memasukkan email/username dan password yang valid.
|
||||||
|
Sistem memvalidasi akun dan mengarahkan pengguna ke halaman Dashboard.
|
||||||
|
|
||||||
|
|
||||||
|
2
|
||||||
|
Login
|
||||||
|
Memasukkan email/username atau password yang tidak sesuai.
|
||||||
|
Sistem menampilkan pesan bahwa email/username atau password salah dan tetap berada pada halaman login.
|
||||||
|
|
||||||
|
|
||||||
|
3
|
||||||
|
Lupa Password
|
||||||
|
Menekan tombol Lupa Password, mengisi email akun, lalu menekan Kirim OTP.
|
||||||
|
Sistem mengirimkan kode OTP ke email pengguna yang terdaftar.
|
||||||
|
|
||||||
|
|
||||||
|
4
|
||||||
|
Reset Password
|
||||||
|
Memasukkan OTP yang valid, password baru, dan konfirmasi password baru.
|
||||||
|
Sistem memperbarui password dan pengguna dapat login menggunakan password baru.
|
||||||
|
|
||||||
|
|
||||||
|
B. Dashboard dan Navigasi
|
||||||
|
5
|
||||||
|
Splash Screen
|
||||||
|
Membuka aplikasi Tobaku Sulastri.
|
||||||
|
Sistem menampilkan splash screen, logo aplikasi, nama aplikasi, dan mengarahkan pengguna sesuai status login.
|
||||||
|
|
||||||
|
|
||||||
|
6
|
||||||
|
Dashboard
|
||||||
|
Mengakses halaman Dashboard setelah login.
|
||||||
|
Sistem menampilkan ringkasan data stok, produk, dan informasi operasional utama.
|
||||||
|
|
||||||
|
|
||||||
|
7
|
||||||
|
Navigasi Menu
|
||||||
|
Menekan menu Produk, Stok Masuk, dan Prediksi pada navigation bar.
|
||||||
|
Sistem menampilkan halaman yang sesuai dengan menu yang dipilih.
|
||||||
|
|
||||||
|
|
||||||
|
8
|
||||||
|
Status Koneksi API
|
||||||
|
Membuka halaman yang memuat data dari server ketika API aktif.
|
||||||
|
Sistem berhasil mengambil data dari API dan menampilkannya pada halaman aplikasi.
|
||||||
|
|
||||||
|
|
||||||
|
C. Data Produk dan Stok Masuk
|
||||||
|
9
|
||||||
|
Data Produk
|
||||||
|
Mengakses menu Produk.
|
||||||
|
Sistem menampilkan daftar produk beserta kategori, harga, stok, satuan, dan status stok.
|
||||||
|
|
||||||
|
|
||||||
|
10
|
||||||
|
Pencarian Produk
|
||||||
|
Memasukkan kata kunci pada kolom pencarian produk.
|
||||||
|
Sistem memfilter daftar produk sesuai nama atau kategori yang dicari.
|
||||||
|
|
||||||
|
|
||||||
|
11
|
||||||
|
Filter Status Stok
|
||||||
|
Memilih filter Semua, Tersedia, Sedang, atau Kritis.
|
||||||
|
Sistem menampilkan produk sesuai status stok yang dipilih.
|
||||||
|
|
||||||
|
|
||||||
|
12
|
||||||
|
Stok Masuk
|
||||||
|
Mengisi data produk, satuan, jumlah, dan harga lalu menyimpan stok masuk.
|
||||||
|
Sistem menyimpan transaksi stok masuk dan memperbarui jumlah stok produk pada database.
|
||||||
|
|
||||||
|
|
||||||
|
D. Prediksi Permintaan Stok
|
||||||
|
13
|
||||||
|
Form Prediksi
|
||||||
|
Mengakses menu Prediksi dan mengisi input prediksi sesuai kebutuhan.
|
||||||
|
Sistem menampilkan form prediksi dan memvalidasi input yang wajib diisi.
|
||||||
|
|
||||||
|
|
||||||
|
14
|
||||||
|
Proses Prediksi
|
||||||
|
Menekan tombol prediksi setelah input lengkap.
|
||||||
|
Sistem memproses prediksi menggunakan model Random Forest dan menampilkan hasil kebutuhan stok bahan kue.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
15
|
||||||
|
Simpan Hasil Prediksi
|
||||||
|
Menyimpan hasil prediksi yang telah ditampilkan.
|
||||||
|
Sistem menyimpan hasil prediksi ke database sehingga data prediksi dapat digunakan sebagai bahan evaluasi.
|
||||||
|
|
||||||
|
|
||||||
|
E. Pengaturan dan Keamanan Akun
|
||||||
|
16
|
||||||
|
Pengaturan Akun
|
||||||
|
Mengakses menu Pengaturan setelah pengguna berhasil login.
|
||||||
|
Sistem menampilkan nama dan email sesuai akun login yang tersimpan pada sesi aplikasi.
|
||||||
|
|
||||||
|
|
||||||
|
17
|
||||||
|
Ubah Password
|
||||||
|
Menekan menu Ubah Password pada bagian Keamanan di halaman Pengaturan.
|
||||||
|
Sistem membuka form reset password berbasis OTP dengan email akun login terisi otomatis dan tidak dapat diubah.
|
||||||
|
|
||||||
|
|
||||||
|
18
|
||||||
|
Reset Password via OTP
|
||||||
|
Menekan Kirim OTP, memasukkan kode OTP yang diterima melalui email, lalu mengisi password baru dan konfirmasi password.
|
||||||
|
Sistem memverifikasi OTP, memperbarui password pada database, dan password baru dapat digunakan untuk login.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Jember, .................2026
|
||||||
|
Penguji (Aktor Verifikator)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
( )
|
||||||
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Setup OTP Email
|
||||||
|
|
||||||
|
Fitur Lupa Password mengirim OTP lewat SMTP Gmail. Konfigurasi SMTP disimpan di file `ml_model/.env`, jadi tidak perlu mengetik environment variable setiap kali menyalakan Flask.
|
||||||
|
|
||||||
|
## 1. Siapkan App Password Gmail
|
||||||
|
|
||||||
|
Gunakan akun email:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sulastri.aritanto10@gmail.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Buat **App Password** dari pengaturan akun Google. Gunakan App Password itu untuk `SMTP_APP_PASSWORD`, bukan password Gmail biasa.
|
||||||
|
|
||||||
|
## 2. Simpan Konfigurasi SMTP
|
||||||
|
|
||||||
|
Pastikan file `ml_model/.env` berisi:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SMTP_EMAIL=sulastri.aritanto10@gmail.com
|
||||||
|
SMTP_APP_PASSWORD=APP_PASSWORD_16_KARAKTER_DARI_GOOGLE
|
||||||
|
SMTP_SENDER_NAME=Tobaku Sulastri
|
||||||
|
```
|
||||||
|
|
||||||
|
File `.env` sudah diabaikan oleh Git supaya App Password tidak ikut tersimpan ke repository.
|
||||||
|
|
||||||
|
## 3. Jalankan Backend Flask
|
||||||
|
|
||||||
|
Di PowerShell, cukup masuk ke folder `ml_model` lalu jalankan Flask:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd ml_model
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Atau dari root project:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python ml_model\app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Testing di Aplikasi
|
||||||
|
|
||||||
|
1. Buka halaman Login.
|
||||||
|
2. Tekan **Lupa Password?**.
|
||||||
|
3. Isi email atau username:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sulastri.aritanto10@gmail.com
|
||||||
|
```
|
||||||
|
|
||||||
|
atau:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Tekan **Kirim OTP**.
|
||||||
|
5. Cek inbox email `sulastri.aritanto10@gmail.com`.
|
||||||
|
6. Masukkan OTP, password baru, dan konfirmasi password.
|
||||||
|
|
||||||
|
OTP berlaku selama 10 menit.
|
||||||
|
|
@ -0,0 +1,277 @@
|
||||||
|
Lembar Pengujian Black Box - Tobaku Sulastri
|
||||||
|
|
||||||
|
Dibuat: 20-05-2026 17:00 | Halaman 1
|
||||||
|
|
||||||
|
Dokumen ini digunakan sebagai lembar checklist pengujian black box aplikasi mobile Tobaku
|
||||||
|
Sulastri. Pengujian berfokus pada input, proses yang terlihat oleh pengguna, output, pesan
|
||||||
|
validasi, dan navigasi aplikasi tanpa memeriksa kode program.
|
||||||
|
|
||||||
|
Identitas Pengujian
|
||||||
|
|
||||||
|
Nama aplikasi: Tobaku Sulastri
|
||||||
|
Jenis pengujian: Black Box Testing
|
||||||
|
Platform uji: Android / perangkat fisik atau emulator
|
||||||
|
Backend: Flask API pada http://192.168.18.30:5000
|
||||||
|
Database: MySQL prediksi_stok_db
|
||||||
|
Tanggal pengujian: ____________________________
|
||||||
|
Tester: ____________________________
|
||||||
|
Versi aplikasi: 1.0.0
|
||||||
|
|
||||||
|
Prasyarat Pengujian
|
||||||
|
|
||||||
|
- Backend Flask sudah berjalan dan dapat diakses dari perangkat uji.
|
||||||
|
- Database MySQL prediksi_stok_db aktif dan memiliki tabel login serta
|
||||||
|
password_reset_otps.
|
||||||
|
- Akun uji tersedia, contoh: admin@sulastri.com / password awal sesuai data pada tabel
|
||||||
|
login.
|
||||||
|
- Perangkat uji berada pada jaringan yang sama dengan server API.
|
||||||
|
- Aplikasi sudah terinstall ulang jika ingin memastikan nama dan ikon launcher terbaru
|
||||||
|
muncul.
|
||||||
|
|
||||||
|
Daftar Skenario Pengujian
|
||||||
|
|
||||||
|
Kolom hasil diisi manual dengan Lulus/Gagal dan catatan singkat saat pengujian.
|
||||||
|
|
||||||
|
AUTH-01 - Splash screen
|
||||||
|
Langkah: Buka aplikasi dari launcher.
|
||||||
|
Hasil yang diharapkan: Splash menampilkan logo kue, nama Tobaku Sulastri, status loading,
|
||||||
|
lalu menuju halaman login/dashboard sesuai sesi.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
AUTH-02 - Login valid
|
||||||
|
Langkah: Isi email/username dan password yang benar, tekan Masuk.
|
||||||
|
Hasil yang diharapkan: Aplikasi masuk ke Dashboard dan menyimpan sesi login.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
AUTH-03 - Login kosong
|
||||||
|
Langkah: Kosongkan email/username atau password, tekan Masuk.
|
||||||
|
Hasil yang diharapkan: Muncul pesan Email dan password tidak boleh kosong.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
AUTH-04 - Login salah
|
||||||
|
Langkah: Isi akun benar dengan password salah, tekan Masuk.
|
||||||
|
Hasil yang diharapkan: Muncul pesan email/username atau password salah dan tetap di
|
||||||
|
halaman login.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
AUTH-05 - Ingat login
|
||||||
|
Langkah: Login berhasil, tutup aplikasi, buka kembali.
|
||||||
|
Hasil yang diharapkan: Aplikasi langsung masuk Dashboard tanpa meminta login ulang.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
AUTH-06 - Logout
|
||||||
|
Langkah: Buka Pengaturan, tekan Logout, konfirmasi Logout.
|
||||||
|
Hasil yang diharapkan: Sesi login terhapus dan aplikasi kembali ke halaman login.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
Lembar Pengujian Black Box - Tobaku Sulastri
|
||||||
|
|
||||||
|
Dibuat: 20-05-2026 17:00 | Halaman 2
|
||||||
|
|
||||||
|
OTP-01 - Kirim OTP valid
|
||||||
|
Langkah: Pada Login tekan Lupa Password, isi email/username valid, tekan Kirim OTP.
|
||||||
|
Hasil yang diharapkan: OTP dibuat, dialog berpindah ke Reset Password, OTP demo tampil.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
OTP-02 - Kirim OTP akun tidak ada
|
||||||
|
Langkah: Isi email/username yang tidak terdaftar, tekan Kirim OTP.
|
||||||
|
Hasil yang diharapkan: Muncul pesan Akun tidak ditemukan.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
OTP-03 - Reset password valid
|
||||||
|
Langkah: Isi OTP demo, password baru minimal 6 karakter, konfirmasi sama, tekan Simpan.
|
||||||
|
Hasil yang diharapkan: Password berhasil diperbarui dan dialog tertutup.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
OTP-04 - OTP salah
|
||||||
|
Langkah: Isi OTP yang salah lalu tekan Simpan.
|
||||||
|
Hasil yang diharapkan: Muncul pesan OTP salah atau sudah kedaluwarsa.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
OTP-05 - Konfirmasi password beda
|
||||||
|
Langkah: Isi password baru dan konfirmasi berbeda.
|
||||||
|
Hasil yang diharapkan: Muncul pesan Konfirmasi password tidak sama.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
OTP-06 - Password terlalu pendek
|
||||||
|
Langkah: Isi password baru kurang dari 6 karakter.
|
||||||
|
Hasil yang diharapkan: Muncul pesan Password baru minimal 6 karakter.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
DASH-01 - Dashboard tampil
|
||||||
|
Langkah: Login lalu buka Dashboard.
|
||||||
|
Hasil yang diharapkan: Ringkasan dashboard tampil: produk, stok, bahan digunakan/prediksi
|
||||||
|
sesuai data API.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
DASH-02 - Navigasi dashboard
|
||||||
|
Langkah: Tekan menu Produk, Stok Masuk, Prediksi, Laporan, Pengaturan.
|
||||||
|
Hasil yang diharapkan: Setiap menu membuka halaman yang sesuai.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PROD-01 - Daftar produk
|
||||||
|
Langkah: Buka halaman Produk.
|
||||||
|
Hasil yang diharapkan: Daftar produk tampil dari database dengan nama, kategori, harga,
|
||||||
|
stok, status.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PROD-02 - Cari produk
|
||||||
|
Langkah: Ketik kata kunci produk/kategori pada kolom pencarian.
|
||||||
|
Hasil yang diharapkan: Daftar produk terfilter sesuai kata kunci.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PROD-03 - Filter status stok
|
||||||
|
Langkah: Pilih filter Semua, Tersedia, Sedang, Kritis.
|
||||||
|
Hasil yang diharapkan: Produk tampil sesuai status stok yang dipilih.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PROD-04 - Detail produk
|
||||||
|
Langkah: Tekan salah satu kartu produk.
|
||||||
|
Hasil yang diharapkan: Bottom sheet detail produk tampil berisi kategori, harga, stok,
|
||||||
|
satuan, kapasitas, status, rekomendasi.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
Lembar Pengujian Black Box - Tobaku Sulastri
|
||||||
|
|
||||||
|
Dibuat: 20-05-2026 17:00 | Halaman 3
|
||||||
|
|
||||||
|
PROD-05 - Tambah stok dari detail
|
||||||
|
Langkah: Pada detail produk tekan Tambah Stok.
|
||||||
|
Hasil yang diharapkan: Aplikasi berpindah ke halaman Stok Masuk.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
STOK-01 - Tambah stok bahan/barang
|
||||||
|
Langkah: Buka Stok Masuk, pilih tipe, produk, satuan, jumlah, harga, lalu tambah ke
|
||||||
|
keranjang.
|
||||||
|
Hasil yang diharapkan: Item masuk ke keranjang dengan perhitungan harga yang sesuai.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
STOK-02 - Validasi stok masuk kosong
|
||||||
|
Langkah: Tekan tambah/simpan tanpa mengisi field wajib.
|
||||||
|
Hasil yang diharapkan: Aplikasi menampilkan pesan validasi dan data tidak disimpan.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
STOK-03 - Simpan stok masuk
|
||||||
|
Langkah: Isi data valid, tambah ke keranjang, lalu simpan transaksi.
|
||||||
|
Hasil yang diharapkan: Transaksi tersimpan ke database dan stok produk bertambah.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
STOK-04 - Tambah produk baru
|
||||||
|
Langkah: Pada Stok Masuk tekan Tambah Produk Baru, isi data valid, simpan.
|
||||||
|
Hasil yang diharapkan: Produk baru tersimpan dan dapat dipilih/ditampilkan pada Produk.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PRED-01 - Muat resep prediksi
|
||||||
|
Langkah: Buka halaman Prediksi.
|
||||||
|
Hasil yang diharapkan: Daftar resep dan input prediksi tampil tanpa error.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PRED-02 - Prediksi valid
|
||||||
|
Langkah: Pilih resep/produk, isi jumlah produksi dan tanggal, tekan prediksi.
|
||||||
|
Hasil yang diharapkan: Aplikasi menampilkan hasil prediksi kebutuhan bahan dan estimasi
|
||||||
|
biaya.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PRED-03 - Validasi prediksi kosong
|
||||||
|
Langkah: Tekan prediksi tanpa input wajib.
|
||||||
|
Hasil yang diharapkan: Muncul pesan validasi dan prediksi tidak dijalankan.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
PRED-04 - Simpan hasil prediksi
|
||||||
|
Langkah: Setelah prediksi berhasil, simpan hasil prediksi.
|
||||||
|
Hasil yang diharapkan: Data prediksi masuk ke database dan muncul di laporan prediksi.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
LAP-01 - Ringkasan laporan
|
||||||
|
Langkah: Buka halaman Laporan.
|
||||||
|
Hasil yang diharapkan: Ringkasan total produk, total bahan, stok kritis, dan total
|
||||||
|
prediksi tampil.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
LAP-02 - Tab laporan stok
|
||||||
|
Langkah: Buka bagian laporan stok/status bahan.
|
||||||
|
Hasil yang diharapkan: Data stok dan status tampil sesuai database.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
LAP-03 - Tab stok masuk
|
||||||
|
Langkah: Buka bagian laporan stok masuk.
|
||||||
|
Hasil yang diharapkan: Riwayat stok masuk tampil sesuai transaksi yang pernah disimpan.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
Lembar Pengujian Black Box - Tobaku Sulastri
|
||||||
|
|
||||||
|
Dibuat: 20-05-2026 17:00 | Halaman 4
|
||||||
|
|
||||||
|
LAP-04 - Tab prediksi
|
||||||
|
Langkah: Buka bagian laporan prediksi.
|
||||||
|
Hasil yang diharapkan: Riwayat prediksi tampil sesuai hasil prediksi yang disimpan.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
LAP-05 - Export laporan PDF/Excel
|
||||||
|
Langkah: Tekan Export Laporan, pilih periode dan format.
|
||||||
|
Hasil yang diharapkan: File laporan berhasil dibuat, dapat dibuka atau dibagikan.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
SET-01 - Halaman pengaturan
|
||||||
|
Langkah: Buka Pengaturan.
|
||||||
|
Hasil yang diharapkan: Informasi akun, aplikasi, dan tombol Logout tampil.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
SET-02 - Ubah password menu
|
||||||
|
Langkah: Tekan opsi Ubah Password bila tersedia.
|
||||||
|
Hasil yang diharapkan: Menu dapat dibuka/menampilkan alur sesuai implementasi atau
|
||||||
|
placeholder yang jelas.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
ERR-01 - Server mati
|
||||||
|
Langkah: Matikan backend Flask lalu buka fitur yang memerlukan API.
|
||||||
|
Hasil yang diharapkan: Aplikasi menampilkan pesan gagal koneksi/gagal memuat data tanpa
|
||||||
|
crash.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
ERR-02 - Database tidak tersedia
|
||||||
|
Langkah: Hentikan MySQL/Laragon lalu lakukan login atau muat produk.
|
||||||
|
Hasil yang diharapkan: Aplikasi menampilkan pesan database/server bermasalah tanpa crash.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
ERR-03 - Jaringan berbeda
|
||||||
|
Langkah: Gunakan perangkat pada jaringan yang tidak bisa akses IP API.
|
||||||
|
Hasil yang diharapkan: Aplikasi menampilkan error koneksi dan tetap dapat digunakan
|
||||||
|
kembali setelah jaringan benar.
|
||||||
|
Hasil aktual/status: _____
|
||||||
|
|
||||||
|
Catatan dan Bukti Pengujian
|
||||||
|
|
||||||
|
Catatan Pengujian:
|
||||||
|
1. Beri tanda Lulus/Gagal pada kolom hasil setelah setiap skenario dijalankan.
|
||||||
|
2. Jika gagal, tuliskan bukti, pesan error, dan langkah reproduksi pada bagian catatan di
|
||||||
|
akhir dokumen.
|
||||||
|
3. OTP saat ini mode demo: kode OTP ditampilkan di dialog aplikasi. Untuk produksi, OTP
|
||||||
|
perlu dikirim via email/WhatsApp.
|
||||||
|
4. Pastikan IP baseUrl di aplikasi sama dengan IP server Flask yang aktif.
|
||||||
|
|
||||||
|
1. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
2. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
3. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
4. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
5. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
6. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
7. ________________________________________________________________________________
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
8. ________________________________________________________________________________
|
||||||
|
Lembar Pengujian Black Box - Tobaku Sulastri
|
||||||
|
|
||||||
|
Dibuat: 20-05-2026 17:00 | Halaman 5
|
||||||
|
|
||||||
|
________________________________________________________________________________
|
||||||
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:finalproject/theme/colors.dart';
|
import 'package:finalproject/theme/colors.dart';
|
||||||
import 'package:finalproject/theme/text_styles.dart';
|
import 'package:finalproject/theme/text_styles.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:finalproject/widgets/password_reset_dialog.dart';
|
||||||
|
|
||||||
import 'login_controller.dart';
|
import 'login_controller.dart';
|
||||||
|
|
||||||
|
|
@ -47,15 +48,15 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onForgotPasswordPressed() {
|
Future<void> _onForgotPasswordPressed() async {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
final success = await showPasswordResetDialog(
|
||||||
SnackBar(
|
context,
|
||||||
content: const Text(
|
initialAccount: _controller.usernameController.text.trim(),
|
||||||
'Fitur lupa password dengan OTP akan dikembangkan pada tahap berikutnya.',
|
|
||||||
),
|
|
||||||
backgroundColor: AppColors.primaryBrown,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
_controller.passwordController.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,9 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SettingsController extends ChangeNotifier {
|
class SettingsController extends ChangeNotifier {
|
||||||
int selectedIndex = 5;
|
int selectedIndex = 5;
|
||||||
bool notificationEnabled = true;
|
|
||||||
|
|
||||||
void setSelectedIndex(int index) {
|
void setSelectedIndex(int index) {
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setNotificationEnabled(bool value) {
|
|
||||||
notificationEnabled = value;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
import 'package:finalproject/services/auth_service.dart';
|
||||||
import 'package:finalproject/theme/colors.dart';
|
import 'package:finalproject/theme/colors.dart';
|
||||||
import 'package:finalproject/theme/text_styles.dart';
|
import 'package:finalproject/theme/text_styles.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:finalproject/services/auth_service.dart';
|
import 'package:finalproject/widgets/password_reset_dialog.dart';
|
||||||
|
|
||||||
import 'settings_controller.dart';
|
import 'settings_controller.dart';
|
||||||
|
|
||||||
|
|
@ -14,11 +15,45 @@ class SettingsScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
late final SettingsController _controller;
|
late final SettingsController _controller;
|
||||||
|
String _userName = 'Ibu Sulastri';
|
||||||
|
String _userEmail = 'sulastri.aritanto10@gmail.com';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = SettingsController();
|
_controller = SettingsController();
|
||||||
|
_loadAccount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAccount() async {
|
||||||
|
final name = await AuthService.getUserName();
|
||||||
|
final email = await AuthService.getUserEmail();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_userName = name;
|
||||||
|
_userEmail = email;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showMessage(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor:
|
||||||
|
isError ? AppColors.statusError : AppColors.primaryBrown,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showResetPasswordDialog() async {
|
||||||
|
final success = await showPasswordResetDialog(
|
||||||
|
context,
|
||||||
|
initialAccount: _userEmail,
|
||||||
|
lockAccountField: true,
|
||||||
|
);
|
||||||
|
if (success) {
|
||||||
|
_showMessage('Password berhasil diperbarui dengan verifikasi OTP');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showLogoutDialog() {
|
void _showLogoutDialog() {
|
||||||
|
|
@ -97,209 +132,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildSectionTitle('Akun'),
|
||||||
'Akun',
|
|
||||||
style: AppTextStyles.headlineSmall.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
_buildAccountCard(),
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.bgWhite,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [AppColors.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primaryBrown,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.person,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Admin Sulastri',
|
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'admin@sulastri.com',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Text(
|
_buildSectionTitle('Keamanan'),
|
||||||
'Umum',
|
|
||||||
style: AppTextStyles.headlineSmall.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
_buildActionCard(
|
||||||
padding: const EdgeInsets.all(16),
|
icon: Icons.lock_outlined,
|
||||||
decoration: BoxDecoration(
|
title: 'Ubah Password',
|
||||||
color: AppColors.bgWhite,
|
subtitle: 'Verifikasi OTP melalui email akun',
|
||||||
borderRadius: BorderRadius.circular(12),
|
onTap: _showResetPasswordDialog,
|
||||||
boxShadow: [AppColors.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.language,
|
|
||||||
color: AppColors.primaryBrown,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Bahasa',
|
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Bahasa Indonesia',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.bgWhite,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [AppColors.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.notifications_outlined,
|
|
||||||
color: AppColors.primaryBrown,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Notifikasi',
|
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Aktifkan notifikasi penting',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
value: _controller.notificationEnabled,
|
|
||||||
onChanged: _controller.setNotificationEnabled,
|
|
||||||
activeColor: AppColors.primaryBrown,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
Text(
|
|
||||||
'Keamanan',
|
|
||||||
style: AppTextStyles.headlineSmall.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.bgWhite,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [AppColors.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.lock_outlined,
|
|
||||||
color: AppColors.primaryBrown,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Ubah Password',
|
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'Perbarui password akun Anda',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|
@ -319,40 +162,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Container(
|
_buildAppInfo(),
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.bgLight,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: AppColors.grey300),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Prediksi Stok Bahan Kue',
|
|
||||||
style: AppTextStyles.labelLarge.copyWith(
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Version 1.0.0',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'© 2025 Tobaku Sulastri',
|
|
||||||
style: AppTextStyles.bodySmall.copyWith(
|
|
||||||
color: AppColors.textTertiary,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -415,6 +225,150 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSectionTitle(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: AppTextStyles.headlineSmall.copyWith(color: AppColors.textPrimary),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAccountCard() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgWhite,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [AppColors.shadowLight],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primaryBrown,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.person, color: Colors.white, size: 32),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_userName,
|
||||||
|
style: AppTextStyles.labelLarge.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_userEmail,
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionCard({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgWhite,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [AppColors.shadowLight],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.primaryBrown, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: AppTextStyles.labelLarge.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, color: AppColors.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAppInfo() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgLight,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.grey300),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Tobaku Sulastri',
|
||||||
|
style: AppTextStyles.labelLarge.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Version 1.0.0',
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'(c) 2025 Tobaku Sulastri',
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,17 @@ class AuthService {
|
||||||
return preferences.getBool(_isLoggedInKey) ?? false;
|
return preferences.getBool(_isLoggedInKey) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<String> getUserName() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
return preferences.getString(_userNameKey) ?? 'Ibu Sulastri';
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> getUserEmail() async {
|
||||||
|
final preferences = await SharedPreferences.getInstance();
|
||||||
|
return preferences.getString(_userEmailKey) ??
|
||||||
|
'sulastri.aritanto10@gmail.com';
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> logout() async {
|
static Future<void> logout() async {
|
||||||
final preferences = await SharedPreferences.getInstance();
|
final preferences = await SharedPreferences.getInstance();
|
||||||
await preferences.remove(_isLoggedInKey);
|
await preferences.remove(_isLoggedInKey);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
class MLService {
|
class MLService {
|
||||||
// API URL - Change based on environment
|
// API URL - Change based on environment
|
||||||
// Untuk emulator Android: 10.0.2.2
|
// Untuk emulator Android: 10.0.2.2
|
||||||
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
|
// Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local
|
||||||
static const String baseUrl = 'http://192.168.18.30:5000';
|
static const String baseUrl = 'http://192.168.1.16:5000';
|
||||||
|
|
||||||
static const int timeoutSeconds = 30;
|
static const int timeoutSeconds = 30;
|
||||||
|
|
||||||
|
|
@ -33,6 +34,92 @@ class MLService {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {'status': 'error', 'message': body['message'] ?? 'Login gagal'};
|
return {'status': 'error', 'message': body['message'] ?? 'Login gagal'};
|
||||||
|
} on TimeoutException {
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message':
|
||||||
|
'Server tidak merespons. Pastikan Flask aktif dan IP/port API sudah benar.',
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tidak dapat terhubung ke server: $e',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> sendForgotPasswordOtp({
|
||||||
|
required String usernameOrEmail,
|
||||||
|
}) async {
|
||||||
|
return _postJson('/api/forgot-password/send-otp', {
|
||||||
|
'email': usernameOrEmail,
|
||||||
|
'username': usernameOrEmail,
|
||||||
|
}, fallbackMessage: 'Gagal membuat OTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> verifyForgotPasswordOtp({
|
||||||
|
required String usernameOrEmail,
|
||||||
|
required String otp,
|
||||||
|
}) async {
|
||||||
|
return _postJson('/api/forgot-password/verify-otp', {
|
||||||
|
'email': usernameOrEmail,
|
||||||
|
'username': usernameOrEmail,
|
||||||
|
'otp': otp,
|
||||||
|
}, fallbackMessage: 'Gagal verifikasi OTP');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> resetPasswordWithOtp({
|
||||||
|
required String usernameOrEmail,
|
||||||
|
required String otp,
|
||||||
|
required String newPassword,
|
||||||
|
}) async {
|
||||||
|
return _postJson('/api/forgot-password/reset', {
|
||||||
|
'email': usernameOrEmail,
|
||||||
|
'username': usernameOrEmail,
|
||||||
|
'otp': otp,
|
||||||
|
'new_password': newPassword,
|
||||||
|
}, fallbackMessage: 'Gagal reset password');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> changePassword({
|
||||||
|
required String usernameOrEmail,
|
||||||
|
required String currentPassword,
|
||||||
|
required String newPassword,
|
||||||
|
}) async {
|
||||||
|
return _postJson('/api/change-password', {
|
||||||
|
'email': usernameOrEmail,
|
||||||
|
'username': usernameOrEmail,
|
||||||
|
'current_password': currentPassword,
|
||||||
|
'new_password': newPassword,
|
||||||
|
}, fallbackMessage: 'Gagal mengubah password');
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> _postJson(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> payload, {
|
||||||
|
required String fallbackMessage,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
Uri.parse('$baseUrl$path'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
)
|
||||||
|
.timeout(Duration(seconds: timeoutSeconds));
|
||||||
|
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {'status': 'error', 'message': body['message'] ?? fallbackMessage};
|
||||||
|
} on TimeoutException {
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message':
|
||||||
|
'Server tidak merespons. Pastikan Flask aktif dan IP/port API sudah benar.',
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,250 @@
|
||||||
|
import 'package:finalproject/services/ml_service.dart';
|
||||||
|
import 'package:finalproject/theme/colors.dart';
|
||||||
|
import 'package:finalproject/theme/text_styles.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Future<bool> showPasswordResetDialog(
|
||||||
|
BuildContext context, {
|
||||||
|
String initialAccount = '',
|
||||||
|
bool lockAccountField = false,
|
||||||
|
}) async {
|
||||||
|
final accountController = TextEditingController(text: initialAccount.trim());
|
||||||
|
final otpController = TextEditingController();
|
||||||
|
final newPasswordController = TextEditingController();
|
||||||
|
final confirmPasswordController = TextEditingController();
|
||||||
|
|
||||||
|
int step = 0;
|
||||||
|
bool isLoading = false;
|
||||||
|
bool resetSuccess = false;
|
||||||
|
String? maskedEmail;
|
||||||
|
|
||||||
|
void showMessage(String message, {bool isError = false}) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(message),
|
||||||
|
backgroundColor:
|
||||||
|
isError ? AppColors.statusError : AppColors.primaryBrown,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) {
|
||||||
|
Future<void> sendOtp() async {
|
||||||
|
final account = accountController.text.trim();
|
||||||
|
if (account.isEmpty) {
|
||||||
|
showMessage('Email atau username wajib diisi', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogState(() => isLoading = true);
|
||||||
|
final result = await MLService.sendForgotPasswordOtp(
|
||||||
|
usernameOrEmail: account,
|
||||||
|
);
|
||||||
|
if (!context.mounted || !dialogContext.mounted) return;
|
||||||
|
|
||||||
|
final success = result['status'] == 'success';
|
||||||
|
setDialogState(() {
|
||||||
|
isLoading = false;
|
||||||
|
if (success) {
|
||||||
|
step = 1;
|
||||||
|
final data = result['data'];
|
||||||
|
maskedEmail = data is Map ? data['email']?.toString() : null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
showMessage(
|
||||||
|
success
|
||||||
|
? 'OTP berhasil dikirim ke email'
|
||||||
|
: result['message']?.toString() ?? 'Gagal membuat OTP',
|
||||||
|
isError: !success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> resetPassword() async {
|
||||||
|
final account = accountController.text.trim();
|
||||||
|
final otp = otpController.text.trim();
|
||||||
|
final newPassword = newPasswordController.text;
|
||||||
|
final confirmPassword = confirmPasswordController.text;
|
||||||
|
|
||||||
|
if (otp.isEmpty || newPassword.isEmpty || confirmPassword.isEmpty) {
|
||||||
|
showMessage('OTP dan password baru wajib diisi', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
showMessage('Password baru minimal 6 karakter', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword != confirmPassword) {
|
||||||
|
showMessage('Konfirmasi password tidak sama', isError: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogState(() => isLoading = true);
|
||||||
|
final verifyResult = await MLService.verifyForgotPasswordOtp(
|
||||||
|
usernameOrEmail: account,
|
||||||
|
otp: otp,
|
||||||
|
);
|
||||||
|
if (!context.mounted || !dialogContext.mounted) return;
|
||||||
|
|
||||||
|
if (verifyResult['status'] != 'success') {
|
||||||
|
setDialogState(() => isLoading = false);
|
||||||
|
showMessage(
|
||||||
|
verifyResult['message']?.toString() ??
|
||||||
|
'OTP salah atau sudah kedaluwarsa',
|
||||||
|
isError: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final resetResult = await MLService.resetPasswordWithOtp(
|
||||||
|
usernameOrEmail: account,
|
||||||
|
otp: otp,
|
||||||
|
newPassword: newPassword,
|
||||||
|
);
|
||||||
|
if (!context.mounted || !dialogContext.mounted) return;
|
||||||
|
|
||||||
|
final success = resetResult['status'] == 'success';
|
||||||
|
setDialogState(() => isLoading = false);
|
||||||
|
|
||||||
|
showMessage(
|
||||||
|
success
|
||||||
|
? 'Password berhasil diperbarui.'
|
||||||
|
: resetResult['message']?.toString() ??
|
||||||
|
'Gagal reset password',
|
||||||
|
isError: !success,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
resetSuccess = true;
|
||||||
|
FocusScope.of(dialogContext).unfocus();
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
step == 0 ? 'Lupa Password' : 'Reset Password',
|
||||||
|
style: AppTextStyles.headlineSmall.copyWith(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: accountController,
|
||||||
|
enabled: !isLoading && step == 0 && !lockAccountField,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email atau username',
|
||||||
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (step == 1) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.bgLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.grey300),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
maskedEmail == null
|
||||||
|
? 'Kode OTP telah dikirim ke email akun Anda.'
|
||||||
|
: 'Kode OTP telah dikirim ke $maskedEmail.',
|
||||||
|
style: AppTextStyles.bodySmall.copyWith(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: otpController,
|
||||||
|
enabled: !isLoading,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Kode OTP',
|
||||||
|
prefixIcon: Icon(Icons.pin_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: newPasswordController,
|
||||||
|
enabled: !isLoading,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password baru',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: confirmPasswordController,
|
||||||
|
enabled: !isLoading,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Konfirmasi password',
|
||||||
|
prefixIcon: Icon(Icons.lock_reset_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed:
|
||||||
|
isLoading ? null : () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('Batal'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
isLoading
|
||||||
|
? null
|
||||||
|
: step == 0
|
||||||
|
? sendOtp
|
||||||
|
: resetPassword,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primaryBrown,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(step == 0 ? 'Kirim OTP' : 'Simpan'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||||
|
accountController.dispose();
|
||||||
|
otpController.dispose();
|
||||||
|
newPasswordController.dispose();
|
||||||
|
confirmPasswordController.dispose();
|
||||||
|
return resetSuccess;
|
||||||
|
}
|
||||||
399
ml_model/app.py
399
ml_model/app.py
|
|
@ -5,16 +5,45 @@ Menggunakan Random Forest Model
|
||||||
|
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
from email.message import EmailMessage
|
||||||
import joblib
|
import joblib
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import hashlib
|
import hashlib
|
||||||
from datetime import datetime
|
import secrets
|
||||||
|
import os
|
||||||
|
import smtplib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import mysql.connector
|
import mysql.connector
|
||||||
from mysql.connector import Error
|
from mysql.connector import Error
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file():
|
||||||
|
"""Load simple KEY=VALUE pairs from ml_model/.env if present."""
|
||||||
|
env_path = os.path.join(os.path.dirname(__file__), '.env')
|
||||||
|
if not os.path.exists(env_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(env_path, 'r', encoding='utf-8') as env_file:
|
||||||
|
for raw_line in env_file:
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith('#') or '=' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key:
|
||||||
|
os.environ[key] = value
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Failed to load .env file: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
load_env_file()
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -30,6 +59,14 @@ DB_USER = 'root'
|
||||||
DB_PASSWORD = '' # Ganti dengan password MySQL Anda jika ada
|
DB_PASSWORD = '' # Ganti dengan password MySQL Anda jika ada
|
||||||
DB_NAME = 'prediksi_stok_db'
|
DB_NAME = 'prediksi_stok_db'
|
||||||
|
|
||||||
|
# Email/SMTP configuration for OTP delivery.
|
||||||
|
# For Gmail, use an App Password, not the regular Gmail password.
|
||||||
|
SMTP_HOST = os.getenv('SMTP_HOST', 'smtp.gmail.com')
|
||||||
|
SMTP_PORT = int(os.getenv('SMTP_PORT', '587'))
|
||||||
|
SMTP_EMAIL = os.getenv('SMTP_EMAIL', '')
|
||||||
|
SMTP_PASSWORD = os.getenv('SMTP_APP_PASSWORD', '').replace(' ', '')
|
||||||
|
SMTP_SENDER_NAME = os.getenv('SMTP_SENDER_NAME', 'Tobaku Sulastri')
|
||||||
|
|
||||||
# Table names (use backticks for names with spaces)
|
# Table names (use backticks for names with spaces)
|
||||||
TRANSACTIONS_TABLE = "`Stock In`"
|
TRANSACTIONS_TABLE = "`Stock In`"
|
||||||
|
|
||||||
|
|
@ -106,6 +143,48 @@ def verify_login_password(input_password: str, stored_password: str) -> bool:
|
||||||
hashed_input = hashlib.sha256(input_password.encode('utf-8')).hexdigest()
|
hashed_input = hashlib.sha256(input_password.encode('utf-8')).hexdigest()
|
||||||
return hashed_input == stored_password
|
return hashed_input == stored_password
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return hashlib.sha256(password.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
def send_otp_email(recipient_email: str, otp_code: str) -> None:
|
||||||
|
if not SMTP_EMAIL or not SMTP_PASSWORD:
|
||||||
|
raise RuntimeError(
|
||||||
|
'SMTP_EMAIL dan SMTP_APP_PASSWORD belum dikonfigurasi'
|
||||||
|
)
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message['Subject'] = 'Kode OTP Reset Password Tobaku Sulastri'
|
||||||
|
message['From'] = f'{SMTP_SENDER_NAME} <{SMTP_EMAIL}>'
|
||||||
|
message['To'] = recipient_email
|
||||||
|
message.set_content(
|
||||||
|
f"""Halo,
|
||||||
|
|
||||||
|
Kode OTP untuk reset password aplikasi Tobaku Sulastri adalah:
|
||||||
|
|
||||||
|
{otp_code}
|
||||||
|
|
||||||
|
Kode ini berlaku selama 10 menit. Abaikan email ini jika Anda tidak meminta reset password.
|
||||||
|
|
||||||
|
Terima kasih,
|
||||||
|
Tobaku Sulastri
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(SMTP_EMAIL, SMTP_PASSWORD)
|
||||||
|
server.send_message(message)
|
||||||
|
|
||||||
|
def mask_email(email: str) -> str:
|
||||||
|
if '@' not in email:
|
||||||
|
return email
|
||||||
|
name, domain = email.split('@', 1)
|
||||||
|
if len(name) <= 2:
|
||||||
|
masked_name = name[0] + '*'
|
||||||
|
else:
|
||||||
|
masked_name = name[:2] + ('*' * max(2, len(name) - 2))
|
||||||
|
return f'{masked_name}@{domain}'
|
||||||
|
|
||||||
def ensure_prediction_needs_column(connection, table_name: str = 'predictions') -> str | None:
|
def ensure_prediction_needs_column(connection, table_name: str = 'predictions') -> str | None:
|
||||||
needs_col = get_existing_column(
|
needs_col = get_existing_column(
|
||||||
connection,
|
connection,
|
||||||
|
|
@ -544,6 +623,324 @@ def login():
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/forgot-password/send-otp', methods=['POST'])
|
||||||
|
def send_forgot_password_otp():
|
||||||
|
"""Create OTP for password reset and send it to the account email."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
username_or_email = (data.get('email') or data.get('username') or '').strip()
|
||||||
|
|
||||||
|
if not username_or_email:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Email/username wajib diisi'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
connection = get_db_connection()
|
||||||
|
if connection is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tidak dapat terhubung ke database'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, name, email, username
|
||||||
|
FROM login
|
||||||
|
WHERE email = %s OR username = %s
|
||||||
|
LIMIT 1
|
||||||
|
""", (username_or_email, username_or_email))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Akun tidak ditemukan'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
otp_code = f"{secrets.randbelow(1000000):06d}"
|
||||||
|
expires_at = datetime.now() + timedelta(minutes=10)
|
||||||
|
|
||||||
|
send_otp_email(user['email'], otp_code)
|
||||||
|
|
||||||
|
connection = get_db_connection()
|
||||||
|
if connection is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tidak dapat terhubung ke database'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO password_reset_otps (login_id, email, otp_code, expires_at)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (user['id'], user['email'], otp_code, expires_at))
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Kode OTP berhasil dikirim ke email. Berlaku selama 10 menit.',
|
||||||
|
'data': {
|
||||||
|
'email': mask_email(user['email'])
|
||||||
|
}
|
||||||
|
}), 200
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error(f"Send OTP configuration error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
|
except smtplib.SMTPAuthenticationError as e:
|
||||||
|
logger.error(f"Send OTP SMTP authentication error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Gagal login ke Gmail. Gunakan App Password Gmail, bukan password email biasa.'
|
||||||
|
}), 500
|
||||||
|
except smtplib.SMTPException as e:
|
||||||
|
logger.error(f"Send OTP SMTP error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Gagal mengirim OTP ke email. Periksa konfigurasi SMTP dan koneksi internet.'
|
||||||
|
}), 500
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Send OTP database error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tabel OTP belum tersedia atau database bermasalah'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Send OTP error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Terjadi kesalahan saat membuat OTP'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/change-password', methods=['POST'])
|
||||||
|
def change_password():
|
||||||
|
"""Change password for a logged-in account using the current password."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
username_or_email = (data.get('email') or data.get('username') or '').strip()
|
||||||
|
current_password = data.get('current_password') or ''
|
||||||
|
new_password = data.get('new_password') or ''
|
||||||
|
|
||||||
|
if not username_or_email or not current_password or not new_password:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Email/username, password lama, dan password baru wajib diisi'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Password baru minimal 6 karakter'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
connection = get_db_connection()
|
||||||
|
if connection is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tidak dapat terhubung ke database'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, password
|
||||||
|
FROM login
|
||||||
|
WHERE email = %s OR username = %s
|
||||||
|
LIMIT 1
|
||||||
|
""", (username_or_email, username_or_email))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Akun tidak ditemukan'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
if not verify_login_password(current_password, user['password']):
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Password lama tidak sesuai'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE login SET password = %s WHERE id = %s",
|
||||||
|
(hash_password(new_password), user['id'])
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Password berhasil diperbarui'
|
||||||
|
}), 200
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Change password database error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Database bermasalah saat mengubah password'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Change password error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Terjadi kesalahan saat mengubah password'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/forgot-password/verify-otp', methods=['POST'])
|
||||||
|
def verify_forgot_password_otp():
|
||||||
|
"""Verify OTP before allowing password reset."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
username_or_email = (data.get('email') or data.get('username') or '').strip()
|
||||||
|
otp_code = (data.get('otp') or '').strip()
|
||||||
|
|
||||||
|
if not username_or_email or not otp_code:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Email/username dan OTP wajib diisi'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
connection = get_db_connection()
|
||||||
|
if connection is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tidak dapat terhubung ke database'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT o.id
|
||||||
|
FROM password_reset_otps o
|
||||||
|
JOIN login l ON l.id = o.login_id
|
||||||
|
WHERE (l.email = %s OR l.username = %s)
|
||||||
|
AND o.otp_code = %s
|
||||||
|
AND o.is_used = 0
|
||||||
|
AND o.expires_at >= NOW()
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", (username_or_email, username_or_email, otp_code))
|
||||||
|
otp = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
if otp is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'OTP salah atau sudah kedaluwarsa'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'OTP valid'
|
||||||
|
}), 200
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Verify OTP database error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tabel OTP belum tersedia atau database bermasalah'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Verify OTP error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Terjadi kesalahan saat verifikasi OTP'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/forgot-password/reset', methods=['POST'])
|
||||||
|
def reset_password_with_otp():
|
||||||
|
"""Reset account password after OTP verification."""
|
||||||
|
try:
|
||||||
|
data = request.json or {}
|
||||||
|
username_or_email = (data.get('email') or data.get('username') or '').strip()
|
||||||
|
otp_code = (data.get('otp') or '').strip()
|
||||||
|
new_password = data.get('new_password') or ''
|
||||||
|
|
||||||
|
if not username_or_email or not otp_code or not new_password:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Email/username, OTP, dan password baru wajib diisi'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if len(new_password) < 6:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Password baru minimal 6 karakter'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
connection = get_db_connection()
|
||||||
|
if connection is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tidak dapat terhubung ke database'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
cursor = connection.cursor(dictionary=True)
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT o.id AS otp_id, l.id AS login_id
|
||||||
|
FROM password_reset_otps o
|
||||||
|
JOIN login l ON l.id = o.login_id
|
||||||
|
WHERE (l.email = %s OR l.username = %s)
|
||||||
|
AND o.otp_code = %s
|
||||||
|
AND o.is_used = 0
|
||||||
|
AND o.expires_at >= NOW()
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", (username_or_email, username_or_email, otp_code))
|
||||||
|
otp = cursor.fetchone()
|
||||||
|
|
||||||
|
if otp is None:
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'OTP salah atau sudah kedaluwarsa'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE login SET password = %s WHERE id = %s",
|
||||||
|
(hash_password(new_password), otp['login_id'])
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE password_reset_otps SET is_used = 1 WHERE id = %s",
|
||||||
|
(otp['otp_id'],)
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Password berhasil diperbarui'
|
||||||
|
}), 200
|
||||||
|
except Error as e:
|
||||||
|
logger.error(f"Reset password database error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Tabel OTP belum tersedia atau database bermasalah'
|
||||||
|
}), 500
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Reset password error: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Terjadi kesalahan saat reset password'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/prediksi', methods=['POST'])
|
@app.route('/prediksi', methods=['POST'])
|
||||||
def prediksi():
|
def prediksi():
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,21 @@ def create_tables():
|
||||||
""")
|
""")
|
||||||
logger.info("Login table created successfully")
|
logger.info("Login table created successfully")
|
||||||
|
|
||||||
|
# Password reset OTP table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_otps (
|
||||||
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
login_id INT NOT NULL,
|
||||||
|
email VARCHAR(100) NOT NULL,
|
||||||
|
otp_code VARCHAR(10) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
is_used TINYINT(1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (login_id) REFERENCES login(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
logger.info("Password reset OTP table created successfully")
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
cursor.close()
|
cursor.close()
|
||||||
connection.close()
|
connection.close()
|
||||||
|
|
@ -160,8 +175,9 @@ def insert_default_login():
|
||||||
VALUES (%s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
name = VALUES(name),
|
name = VALUES(name),
|
||||||
|
email = VALUES(email),
|
||||||
username = VALUES(username)
|
username = VALUES(username)
|
||||||
""", ('Ibu Sulastri', 'admin@sulastri.com', 'admin', 'password'))
|
""", ('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password'))
|
||||||
|
|
||||||
connection.commit()
|
connection.commit()
|
||||||
logger.info("Default login account ready")
|
logger.info("Default login account ready")
|
||||||
|
|
@ -214,7 +230,7 @@ def main():
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
print("✅ Database setup completed successfully!")
|
print("Database setup completed successfully!")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,18 @@ CREATE TABLE IF NOT EXISTS login (
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Create Password Reset OTP Table
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_otps (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
login_id INT NOT NULL,
|
||||||
|
email VARCHAR(100) NOT NULL,
|
||||||
|
otp_code VARCHAR(10) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
is_used TINYINT(1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (login_id) REFERENCES login(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Create Recipes Table
|
-- Create Recipes Table
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
@ -100,9 +112,10 @@ INSERT INTO products (name, category, product_type, unit, price, current_stock)
|
||||||
|
|
||||||
-- Insert Default Login Account
|
-- Insert Default Login Account
|
||||||
INSERT INTO login (name, email, username, password) VALUES
|
INSERT INTO login (name, email, username, password) VALUES
|
||||||
('Ibu Sulastri', 'admin@sulastri.com', 'admin', 'password')
|
('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password')
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
name = VALUES(name),
|
name = VALUES(name),
|
||||||
|
email = VALUES(email),
|
||||||
username = VALUES(username);
|
username = VALUES(username);
|
||||||
|
|
||||||
-- Insert Sample Recipes
|
-- Insert Sample Recipes
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue