memperbarui lupa password serta OTP pada email dan fitur setting telah dirubah dan disederhanakan, backend dan API

This commit is contained in:
rhanarmt 2026-05-21 00:58:53 +07:00
parent 7502d80d4b
commit cc37039155
13 changed files with 1041 additions and 254 deletions

2
.gitignore vendored
View File

@ -3,6 +3,8 @@
*.log
*.pyc
*.swp
.env
**/.env
.DS_Store
.atom/
.build/

View File

@ -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)
( )

62
docs/SMTP_OTP_SETUP.md Normal file
View File

@ -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.

View File

@ -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
________________________________________________________________________________

View File

@ -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

View File

@ -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();
}
}

View File

@ -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();

View File

@ -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);

View File

@ -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',

View File

@ -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;
}

View File

@ -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():
"""

View File

@ -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__":

View File

@ -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