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
|
||||
*.pyc
|
||||
*.swp
|
||||
.env
|
||||
**/.env
|
||||
.DS_Store
|
||||
.atom/
|
||||
.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/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<LoginScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
void _onForgotPasswordPressed() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'Fitur lupa password dengan OTP akan dikembangkan pada tahap berikutnya.',
|
||||
),
|
||||
backgroundColor: AppColors.primaryBrown,
|
||||
),
|
||||
Future<void> _onForgotPasswordPressed() async {
|
||||
final success = await showPasswordResetDialog(
|
||||
context,
|
||||
initialAccount: _controller.usernameController.text.trim(),
|
||||
);
|
||||
|
||||
if (success) {
|
||||
_controller.passwordController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SettingsScreen> {
|
||||
late final SettingsController _controller;
|
||||
String _userName = 'Ibu Sulastri';
|
||||
String _userEmail = 'sulastri.aritanto10@gmail.com';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_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() {
|
||||
|
|
@ -97,209 +132,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
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<SettingsScreen> {
|
|||
),
|
||||
),
|
||||
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<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
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,17 @@ class AuthService {
|
|||
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 {
|
||||
final preferences = await SharedPreferences.getInstance();
|
||||
await preferences.remove(_isLoggedInKey);
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
return {
|
||||
'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_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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue