diff --git a/.gitignore b/.gitignore index 79c113f..07fa899 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ *.log *.pyc *.swp +.env +**/.env .DS_Store .atom/ .build/ diff --git a/docs/LEMBAR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx b/docs/LEMBAR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx new file mode 100644 index 0000000..4bbfdb9 --- /dev/null +++ b/docs/LEMBAR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx @@ -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) + + + + ( ) + diff --git a/docs/SMTP_OTP_SETUP.md b/docs/SMTP_OTP_SETUP.md new file mode 100644 index 0000000..5c2dbef --- /dev/null +++ b/docs/SMTP_OTP_SETUP.md @@ -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. diff --git a/docs/lembar_pengujian_blackbox_tobaku_sulastri.pdf b/docs/lembar_pengujian_blackbox_tobaku_sulastri.pdf new file mode 100644 index 0000000..cc0c95b --- /dev/null +++ b/docs/lembar_pengujian_blackbox_tobaku_sulastri.pdf @@ -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 + + ________________________________________________________________________________ + diff --git a/lib/screens/login/login_page.dart b/lib/screens/login/login_page.dart index 56a36ab..13f0b4f 100644 --- a/lib/screens/login/login_page.dart +++ b/lib/screens/login/login_page.dart @@ -1,6 +1,7 @@ import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.dart'; import 'package:flutter/material.dart'; +import 'package:finalproject/widgets/password_reset_dialog.dart'; import 'login_controller.dart'; @@ -47,15 +48,15 @@ class _LoginScreenState extends State { } } - void _onForgotPasswordPressed() { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Fitur lupa password dengan OTP akan dikembangkan pada tahap berikutnya.', - ), - backgroundColor: AppColors.primaryBrown, - ), + Future _onForgotPasswordPressed() async { + final success = await showPasswordResetDialog( + context, + initialAccount: _controller.usernameController.text.trim(), ); + + if (success) { + _controller.passwordController.clear(); + } } @override diff --git a/lib/screens/settings/settings_controller.dart b/lib/screens/settings/settings_controller.dart index 10553ea..e322489 100644 --- a/lib/screens/settings/settings_controller.dart +++ b/lib/screens/settings/settings_controller.dart @@ -2,15 +2,9 @@ import 'package:flutter/material.dart'; class SettingsController extends ChangeNotifier { int selectedIndex = 5; - bool notificationEnabled = true; void setSelectedIndex(int index) { selectedIndex = index; notifyListeners(); } - - void setNotificationEnabled(bool value) { - notificationEnabled = value; - notifyListeners(); - } } diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index 96188d5..de299bc 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -1,7 +1,8 @@ +import 'package:finalproject/services/auth_service.dart'; import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.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'; @@ -14,11 +15,45 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { late final SettingsController _controller; + String _userName = 'Ibu Sulastri'; + String _userEmail = 'sulastri.aritanto10@gmail.com'; @override void initState() { super.initState(); _controller = SettingsController(); + _loadAccount(); + } + + Future _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 _showResetPasswordDialog() async { + final success = await showPasswordResetDialog( + context, + initialAccount: _userEmail, + lockAccountField: true, + ); + if (success) { + _showMessage('Password berhasil diperbarui dengan verifikasi OTP'); + } } void _showLogoutDialog() { @@ -97,209 +132,17 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Akun', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), + _buildSectionTitle('Akun'), const SizedBox(height: 16), - 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( - '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, - ), - ), - ], - ), - ), - ], - ), - ), + _buildAccountCard(), const SizedBox(height: 32), - Text( - 'Umum', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), + _buildSectionTitle('Keamanan'), 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.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, - ), - ], - ), + _buildActionCard( + icon: Icons.lock_outlined, + title: 'Ubah Password', + subtitle: 'Verifikasi OTP melalui email akun', + onTap: _showResetPasswordDialog, ), const SizedBox(height: 32), SizedBox( @@ -319,40 +162,7 @@ class _SettingsScreenState extends State { ), ), const SizedBox(height: 32), - 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( - '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, - ), - ], - ), - ), + _buildAppInfo(), ], ), ), @@ -415,6 +225,150 @@ class _SettingsScreenState extends State { ); } + 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 void dispose() { _controller.dispose(); diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 83f03b3..7b576a4 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -23,6 +23,17 @@ class AuthService { return preferences.getBool(_isLoggedInKey) ?? false; } + static Future getUserName() async { + final preferences = await SharedPreferences.getInstance(); + return preferences.getString(_userNameKey) ?? 'Ibu Sulastri'; + } + + static Future getUserEmail() async { + final preferences = await SharedPreferences.getInstance(); + return preferences.getString(_userEmailKey) ?? + 'sulastri.aritanto10@gmail.com'; + } + static Future logout() async { final preferences = await SharedPreferences.getInstance(); await preferences.remove(_isLoggedInKey); diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 94ac8ff..2940831 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -1,11 +1,12 @@ import 'package:http/http.dart' as http; +import 'dart:async'; import 'dart:convert'; class MLService { // API URL - Change based on environment // Untuk emulator Android: 10.0.2.2 // 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; @@ -33,6 +34,92 @@ class MLService { } 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> sendForgotPasswordOtp({ + required String usernameOrEmail, + }) async { + return _postJson('/api/forgot-password/send-otp', { + 'email': usernameOrEmail, + 'username': usernameOrEmail, + }, fallbackMessage: 'Gagal membuat OTP'); + } + + static Future> 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> 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> 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> _postJson( + String path, + Map 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; + 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) { return { 'status': 'error', diff --git a/lib/widgets/password_reset_dialog.dart b/lib/widgets/password_reset_dialog.dart new file mode 100644 index 0000000..15b1ff4 --- /dev/null +++ b/lib/widgets/password_reset_dialog.dart @@ -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 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 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 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.delayed(const Duration(milliseconds: 300)); + accountController.dispose(); + otpController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + return resetSuccess; +} diff --git a/ml_model/app.py b/ml_model/app.py index 2a732ce..26ba6c4 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -5,16 +5,45 @@ Menggunakan Random Forest Model from flask import Flask, request, jsonify from flask_cors import CORS +from email.message import EmailMessage import joblib import pandas as pd import numpy as np import logging import math import hashlib -from datetime import datetime +import secrets +import os +import smtplib +from datetime import datetime, timedelta import mysql.connector 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 logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -30,6 +59,14 @@ DB_USER = 'root' DB_PASSWORD = '' # Ganti dengan password MySQL Anda jika ada 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) 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() 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: needs_col = get_existing_column( connection, @@ -544,6 +623,324 @@ def login(): }), 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']) def prediksi(): """ diff --git a/ml_model/database_setup.py b/ml_model/database_setup.py index e48a54d..521b914 100644 --- a/ml_model/database_setup.py +++ b/ml_model/database_setup.py @@ -115,6 +115,21 @@ def create_tables(): """) 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() cursor.close() connection.close() @@ -160,8 +175,9 @@ def insert_default_login(): VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE name = VALUES(name), + email = VALUES(email), username = VALUES(username) - """, ('Ibu Sulastri', 'admin@sulastri.com', 'admin', 'password')) + """, ('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password')) connection.commit() logger.info("Default login account ready") @@ -214,7 +230,7 @@ def main(): print() print("=" * 60) - print("✅ Database setup completed successfully!") + print("Database setup completed successfully!") print("=" * 60) if __name__ == "__main__": diff --git a/ml_model/setup_database.sql b/ml_model/setup_database.sql index 8fe6593..615a933 100644 --- a/ml_model/setup_database.sql +++ b/ml_model/setup_database.sql @@ -69,6 +69,18 @@ CREATE TABLE IF NOT EXISTS login ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) 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 TABLE IF NOT EXISTS recipes ( 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 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 name = VALUES(name), + email = VALUES(email), username = VALUES(username); -- Insert Sample Recipes