From cc37039155d0faaa5c15fcd0cdd2fceb30365b88 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Thu, 21 May 2026 00:58:53 +0700 Subject: [PATCH] memperbarui lupa password serta OTP pada email dan fitur setting telah dirubah dan disederhanakan, backend dan API --- .gitignore | 2 + ...AR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx | Bin 0 -> 21489 bytes docs/SMTP_OTP_SETUP.md | 62 +++ ...bar_pengujian_blackbox_tobaku_sulastri.pdf | Bin 0 -> 6592 bytes lib/screens/login/login_page.dart | 17 +- lib/screens/settings/settings_controller.dart | 6 - lib/screens/settings/settings_page.dart | 424 ++++++++---------- lib/services/auth_service.dart | 11 + lib/services/ml_service.dart | 89 +++- lib/widgets/password_reset_dialog.dart | 250 +++++++++++ ml_model/app.py | 399 +++++++++++++++- ml_model/database_setup.py | 20 +- ml_model/setup_database.sql | 15 +- 13 files changed, 1041 insertions(+), 254 deletions(-) create mode 100644 docs/LEMBAR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx create mode 100644 docs/SMTP_OTP_SETUP.md create mode 100644 docs/lembar_pengujian_blackbox_tobaku_sulastri.pdf create mode 100644 lib/widgets/password_reset_dialog.dart diff --git a/.gitignore b/.gitignore index 79c113f..07fa899 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ *.log *.pyc *.swp +.env +**/.env .DS_Store .atom/ .build/ diff --git a/docs/LEMBAR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx b/docs/LEMBAR PENGUJIAN SISTEM - TOBAKU SULASTRI.docx new file mode 100644 index 0000000000000000000000000000000000000000..4bbfdb9c489db4fd255c564b583391777ac785d0 GIT binary patch literal 21489 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 V#<(7Ea7ZxUhnTi&s`K;LzW@fBLk|D| literal 0 HcmV?d00001 diff --git a/docs/SMTP_OTP_SETUP.md b/docs/SMTP_OTP_SETUP.md new file mode 100644 index 0000000..5c2dbef --- /dev/null +++ b/docs/SMTP_OTP_SETUP.md @@ -0,0 +1,62 @@ +# Setup OTP Email + +Fitur Lupa Password mengirim OTP lewat SMTP Gmail. Konfigurasi SMTP disimpan di file `ml_model/.env`, jadi tidak perlu mengetik environment variable setiap kali menyalakan Flask. + +## 1. Siapkan App Password Gmail + +Gunakan akun email: + +```text +sulastri.aritanto10@gmail.com +``` + +Buat **App Password** dari pengaturan akun Google. Gunakan App Password itu untuk `SMTP_APP_PASSWORD`, bukan password Gmail biasa. + +## 2. Simpan Konfigurasi SMTP + +Pastikan file `ml_model/.env` berisi: + +```text +SMTP_EMAIL=sulastri.aritanto10@gmail.com +SMTP_APP_PASSWORD=APP_PASSWORD_16_KARAKTER_DARI_GOOGLE +SMTP_SENDER_NAME=Tobaku Sulastri +``` + +File `.env` sudah diabaikan oleh Git supaya App Password tidak ikut tersimpan ke repository. + +## 3. Jalankan Backend Flask + +Di PowerShell, cukup masuk ke folder `ml_model` lalu jalankan Flask: + +```powershell +cd ml_model +python app.py +``` + +Atau dari root project: + +```powershell +python ml_model\app.py +``` + +## 4. Testing di Aplikasi + +1. Buka halaman Login. +2. Tekan **Lupa Password?**. +3. Isi email atau username: + +```text +sulastri.aritanto10@gmail.com +``` + +atau: + +```text +admin +``` + +4. Tekan **Kirim OTP**. +5. Cek inbox email `sulastri.aritanto10@gmail.com`. +6. Masukkan OTP, password baru, dan konfirmasi password. + +OTP berlaku selama 10 menit. diff --git a/docs/lembar_pengujian_blackbox_tobaku_sulastri.pdf b/docs/lembar_pengujian_blackbox_tobaku_sulastri.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cc0c95b64340429ad664193dc509f3dbc9e73f0a GIT binary patch literal 6592 zcmchcbyQT_+s759B_sujQDSHqU?@SlC8c|S0fv?a=|<8K>5vBLM!J!fl#l_*A*ECB z1@G^^*ZcGRjYqb?riMz^Xp9z1Yj3~I70vUMFwi^0(F9!KmbplVnJ<9Z(4Ky z|Ff`)*;$+Zl8Wn>RL`Kc=1vv>5EmEMb-!TNPEd#Ivo*vCDgiaIGlkwXb98cmLTs?y zQgSqBZD*zMy@oXhOt6t+s;)v4QtnkEb-+2HtwP6Igk#~xRgcz6Mz=5sa9 zR50DpN(z^`RD81QQZ(u^*VGn50b9$A%$n(RB?(MtSqE|Ww!<%1M^sn*RrL~Ed@7&7 z3cXhGU3thU44_YLTR$m$jR9P`pKzJUKXoEsL%X0(gzY=0u zGss`56-^+uz8cp=jHqps)BDNe63;&g1EX>jbqYD)mtSrB*;utj1|5&fk_j*t5L%;_ zj@VnM^`dT`%P~L%u<0mnKRB{yN<^qY(R7MpURkA5iWI(upiwFBv~jYY-ezTJ!_8nZ zytoxxxz{xC?pZ#BK=ksJzvg(yEv7!UJ?8m~D8*v!SXQwP@?3Wtd`oqZ;jn=T*SR}6 zT>Llzm>wvoN&F^Gmpi(tS#R018Uyc#>~fl%PTocysX0H+iz_2|vNY3YA$8k?a=jfw zW7d8AO{sTtjB~ewbUK2HsuqbO80+biEFW9+;FR8G6)?h?uzahCG2eRt$15#Up z2o6s!%nuEy-u4h2((GbLXO}*6;O?EQWa%dmbFFx(MEBNWio99y%ayZ&b;YZ?71!?f z=Os*pkDV8%mGfQ_c}aXrJCnmmDK$1S4p`@v?q7X0BmC@ywkg;%HZrYs^I1bBGcBUNL1BXJHQ%NaXJAxclgCn4N-*0CH(u#?ktO(HY<7Hgv*SDoq|Y_IZU zUH7oYEMJwgq8P40Iq9q3v0}H1e62fiy1uc0+nZ+YzFyQoeP-N}Q=lTjzAGm-Yu@-Q zCQJqexwoi&s4c$nGE-p(b8=y{`w59Q;^`LAN@yo+{SQ`MQ3~a^n|O0YB=DN><;qC2gv zlUSSW>@U{Ty_T1n^pshYg*qQ5y3~4UCb=Gp#O<{LrjY%JLO6Ox>pn(!1iCMG0*-l( z-gKQ1kiM{HyFz%9oAJ-l*Rrl}-!dyClTX-(Q8BmRz6VSg)Stgl@6vt>k7@TNL0@|A z3t%Y7gqD^UH+^vF8kzQ2=nq;;rY(BsVvV2p0|LhNB{Er0!pCApf$chAzJc;SWJnoa z-k}5$3>R{+n+TBN=7^EE+g>3xUr8&F92ht*i+67EO@8LT>Kk+7eM&VR5pjWG z#ch7pn#?nW-o!jnKreaa5>cs*x73Tdq(gSJ7$gBEDqN&k9{wtdhrm+~nMH;kS>0wM zDO|gCMR1QLtz)-sO~`z5bb52x%}26lNY!Czp6#pgWc=>o-O`LlPiSK~&cXsmN=E== zS?X!$_aKImHhV>G!)0DkV+lgH>=L`;GHGdtvx0~9fEh^7YELeH_Y-X{I2VmW35McB zD;Wkzvj7mND(@<$WhoI*St7I@ay!;rfBdQ=SEl@vCyx>*u@B&Zbg7i9dsW<64?o+w z*(;zSA5ayp1hng^)jHfwmEIeZ{v5bv6l21pBa5zh}|uljD@I zGF&qMhVbI$hHo*K=uDpT{QdR0@70U?SJd0yQ{U0IYxbZw`2G(+{{wRFU+DXX94JG~ zuSG!_;sCvt1<(zhRRQb@P*WI0%+3v<%W)k5^Me6=+?@IVc2%gOowI`p)Ddu_CqMP# z=LHCGqchhHr8ogMTA_MP?wb$0__f$vCw2tI7RLY3ey8D8z+yD#S2Ks%B-pI?ow}4_#(?I6a+j z+}N4x?15NJ>&6j1E@b1PjAE1Vd>%nD?EQEN7Zi z$e|%)OKs+^uRLy%lz~E(X=Yo}v>L+x%%JT_$ntCO(rvoZ1w;5P)cMTTsKxHSk1lwtfKErapmc;Atq z0(SG!@1lM9h|S`s*Te!$J?j}fJs%<~#jmo^&4F4~zM-P?5giN!`m2zF1%x8=$l(tR*W331|C2wMa`Y znv!t;#k+N1Do3*WvTi8LCZ0HKGh+1+%Nr1^t3O1!i(t1zF^d4Jgt_DK^eMCn$uaK@-q1;a78 zP5u<_Y^e~PN2xwRP}>p713sxqcf+i(N=VYCn@!A<49<>@>A=FmC z_;EblX{EWZL5>}>>UvNS&OMzd#+7E^+E-&~<*3o9%n2;(0GtLmyX`X6 zEwbH0mGu}J{~mhU)~9m2;fX+2ADzCZP^l>2>V);jlV>9rNnGWk_~}U>U0`psyC^OX zr#~o0w+hRzW~#r^ir1C0Q|Z#$9MYO09ei>%TMii>?mx?m)1aB7Af+26=_2l%1MG>E zRph)@>WRa?(%yfla9(y!O%!ot^*b+le!s6Df@alDp@)8(PL;EnolU_=l{ z;pM;u3N3qRJZ5^2{p`K|@9WJ9x&;){*@lBNy+ zPFne9@niRXSy~p;=#|Vj3>UVjJ~-^yqWXpq>#&QJ;{8&3y}>tWg67mQNh^(UOZ11iJk zc;){x=bXhIb)waiVT3F}Q1x5B29Di{*zrcgyM?wmNO|}z1Rxl64-uB?Qq`=X5_>rV>FNftZ~8{b zY=o1BSVEuUAV1@V4|SVC+&(X_W4=n#w_0G_6#;fcF*m6@`g_Q3D@NIE4GY$vhsHk(If7ev1{8endYVW5q3C(z>%xhbncgATdpHP zn>-hNh|=rXfnIKfwi5yrqE3TFi1$B1C}X;@eH0*3Yd`I_;8SRdY%A|&tlL}#dcLl5 z963Z|m7F`=e@0Xln;WuMBUHl!x;*2-ZM4r^`joFEYh|ciW?#x$6kH(Z{DM3;K>G*) z-t2ICCg)b$F$#KWw}pE0rnYvgss4n;qpi~5xa!TP3rxvSS5@VPsY(L4VtlQ%~y!{TKcsI6VHb4u(s8g zZJ0MF50J#MPD@M~rKHElxu@O>kB@(E`(b(Jm|mP*4eM~cyi`n!b|4e!U7C?$kL!v6 z8ax}4(|kRtjF8%K;y9>&ij5#hC3;@YUK$qoGg-2X%Bwe zu+v%|$mNH}pQ5g0k21^D)fmvAXf!5w#cYk2u4j)NZnVS3+7KJgE%aZ#+bk1Qv3`P-X z#vj)}al*{E=@3Zd`d-%0nQq+?gSR$xEqS%1>?=wdBa^9TG{GiCKk=9SZ>JI45AN%* zSk{+A6hbJDJto}B4H3JU-J|p$8ooc`U$qG_9;upg8N_OSACHe2})36OC+cPpcP2H#|6t&l`JNo^49Y ztuNb;cdR~HB{wNSxh3?4b@}u8u+uMCn+#Y_Bb|Pz$j|av8U;|+sMW}w`yNUFL#jmOe%1T_+KOcM4^samA6@DERUR=BH zeZmNbm%*AfIRpIP&Rqlr_7*Y~TW)Cf%--8mWy9B@e@L!WWdVvnsIB3=JrN7UX42}& z+s{HS8)0+*@E_^Ya1@cN{zysgKDj(*Z5S3I`@GHXwfrm*bshCu57%g8T@(1c*<}AA zu~>%)u*=H?L zeA(L(O*B!IaYDJHEMo9Bg5@Q|TjgAFL{4u+A{MMe6eCgZiyk(J$X%HF!67XAfci}3 zz)=x3Z;HdhJ@_o&Io#5JflgC1TSH-T@L-O5vnwg)i+au@`IyLj`OKNUQNGgP!Z`KG z6`rPx9VPhNBOEsJEQuMSk5eC=PlADE!Sf1Faa)!szn)-Tu`}>b*B}a2zr==3=hrs^Zp!4NUkvGh(Zj!VV7HD$e zZ=(BVpSj*Ghk$?#0=aM2ooipqOPGvqnMv1SHo;N zgEX%ZR4{#$vFFK=4tDdwZL#D11i*}mr`7b@HIPuO#bwU?@2k?{tnd9B(T z*68DkGjmmUXZp7C*%rG1UnM&BVxM;3*YeY2x%y%$99Fezts(RPY1D2bwdf3C4FDn^Z@Al5oQb`Dl?(-#- z9ekuTGxJ}*Bpl^VJyFtVqfI>-HXU<92&V)+cWREvwGsqfktmF~3T8AN;i2pid7%>~ z>HYw`{x0lgX#E2Xbc;Lucl3g8%J4tS<$smEH>~}Yy`WzW{I`l`H>`ss7#T{+BG0pHus{ zx&GBF|Ldtc0@&qYrj7vJ8#6~0a8shF0&c`v74UPNT%Ue=FxP&KxSjL0s{{U}jsX4c z0Et7KAl7!~HzttzwX^dd59qJb!_5I|hQ$E@fw4IL_WNRx?Yffk8y+ee#^(r#ra!4 zFqrRpb^pCBn3wZhwm+WR&vzRT2dAIY%frdZgGEOtsU-D34+mZg literal 0 HcmV?d00001 diff --git a/lib/screens/login/login_page.dart b/lib/screens/login/login_page.dart index 56a36ab..13f0b4f 100644 --- a/lib/screens/login/login_page.dart +++ b/lib/screens/login/login_page.dart @@ -1,6 +1,7 @@ import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.dart'; import 'package:flutter/material.dart'; +import 'package:finalproject/widgets/password_reset_dialog.dart'; import 'login_controller.dart'; @@ -47,15 +48,15 @@ class _LoginScreenState extends State { } } - void _onForgotPasswordPressed() { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Fitur lupa password dengan OTP akan dikembangkan pada tahap berikutnya.', - ), - backgroundColor: AppColors.primaryBrown, - ), + Future _onForgotPasswordPressed() async { + final success = await showPasswordResetDialog( + context, + initialAccount: _controller.usernameController.text.trim(), ); + + if (success) { + _controller.passwordController.clear(); + } } @override diff --git a/lib/screens/settings/settings_controller.dart b/lib/screens/settings/settings_controller.dart index 10553ea..e322489 100644 --- a/lib/screens/settings/settings_controller.dart +++ b/lib/screens/settings/settings_controller.dart @@ -2,15 +2,9 @@ import 'package:flutter/material.dart'; class SettingsController extends ChangeNotifier { int selectedIndex = 5; - bool notificationEnabled = true; void setSelectedIndex(int index) { selectedIndex = index; notifyListeners(); } - - void setNotificationEnabled(bool value) { - notificationEnabled = value; - notifyListeners(); - } } diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index 96188d5..de299bc 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -1,7 +1,8 @@ +import 'package:finalproject/services/auth_service.dart'; import 'package:finalproject/theme/colors.dart'; import 'package:finalproject/theme/text_styles.dart'; import 'package:flutter/material.dart'; -import 'package:finalproject/services/auth_service.dart'; +import 'package:finalproject/widgets/password_reset_dialog.dart'; import 'settings_controller.dart'; @@ -14,11 +15,45 @@ class SettingsScreen extends StatefulWidget { class _SettingsScreenState extends State { late final SettingsController _controller; + String _userName = 'Ibu Sulastri'; + String _userEmail = 'sulastri.aritanto10@gmail.com'; @override void initState() { super.initState(); _controller = SettingsController(); + _loadAccount(); + } + + Future _loadAccount() async { + final name = await AuthService.getUserName(); + final email = await AuthService.getUserEmail(); + if (!mounted) return; + setState(() { + _userName = name; + _userEmail = email; + }); + } + + void _showMessage(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: + isError ? AppColors.statusError : AppColors.primaryBrown, + ), + ); + } + + Future _showResetPasswordDialog() async { + final success = await showPasswordResetDialog( + context, + initialAccount: _userEmail, + lockAccountField: true, + ); + if (success) { + _showMessage('Password berhasil diperbarui dengan verifikasi OTP'); + } } void _showLogoutDialog() { @@ -97,209 +132,17 @@ class _SettingsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Akun', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), + _buildSectionTitle('Akun'), const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - children: [ - Container( - width: 60, - height: 60, - decoration: BoxDecoration( - color: AppColors.primaryBrown, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.person, - color: Colors.white, - size: 32, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Admin Sulastri', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - 'admin@sulastri.com', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ), + _buildAccountCard(), const SizedBox(height: 32), - Text( - 'Umum', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), + _buildSectionTitle('Keamanan'), const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - Icons.language, - color: AppColors.primaryBrown, - size: 24, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Bahasa', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Bahasa Indonesia', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - Icon( - Icons.chevron_right, - color: AppColors.textSecondary, - ), - ], - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - Icons.notifications_outlined, - color: AppColors.primaryBrown, - size: 24, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Notifikasi', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Aktifkan notifikasi penting', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - Switch( - value: _controller.notificationEnabled, - onChanged: _controller.setNotificationEnabled, - activeColor: AppColors.primaryBrown, - ), - ], - ), - ), - const SizedBox(height: 32), - Text( - 'Keamanan', - style: AppTextStyles.headlineSmall.copyWith( - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgWhite, - borderRadius: BorderRadius.circular(12), - boxShadow: [AppColors.shadowLight], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - Icons.lock_outlined, - color: AppColors.primaryBrown, - size: 24, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Ubah Password', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - ), - ), - Text( - 'Perbarui password akun Anda', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - Icon( - Icons.chevron_right, - color: AppColors.textSecondary, - ), - ], - ), + _buildActionCard( + icon: Icons.lock_outlined, + title: 'Ubah Password', + subtitle: 'Verifikasi OTP melalui email akun', + onTap: _showResetPasswordDialog, ), const SizedBox(height: 32), SizedBox( @@ -319,40 +162,7 @@ class _SettingsScreenState extends State { ), ), const SizedBox(height: 32), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.bgLight, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.grey300), - ), - child: Column( - children: [ - Text( - 'Prediksi Stok Bahan Kue', - style: AppTextStyles.labelLarge.copyWith( - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'Version 1.0.0', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 12), - Text( - '© 2025 Tobaku Sulastri', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textTertiary, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), + _buildAppInfo(), ], ), ), @@ -415,6 +225,150 @@ class _SettingsScreenState extends State { ); } + Widget _buildSectionTitle(String title) { + return Text( + title, + style: AppTextStyles.headlineSmall.copyWith(color: AppColors.textPrimary), + ); + } + + Widget _buildAccountCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(16), + boxShadow: [AppColors.shadowLight], + ), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: AppColors.primaryBrown, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.person, color: Colors.white, size: 32), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _userName, + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + _userEmail, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActionCard({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgWhite, + borderRadius: BorderRadius.circular(12), + boxShadow: [AppColors.shadowLight], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Icon(icon, color: AppColors.primaryBrown, size: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: AppColors.textSecondary), + ], + ), + ), + ); + } + + Widget _buildAppInfo() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.grey300), + ), + child: Column( + children: [ + Text( + 'Tobaku Sulastri', + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Version 1.0.0', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 12), + Text( + '(c) 2025 Tobaku Sulastri', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textTertiary, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + @override void dispose() { _controller.dispose(); diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart index 83f03b3..7b576a4 100644 --- a/lib/services/auth_service.dart +++ b/lib/services/auth_service.dart @@ -23,6 +23,17 @@ class AuthService { return preferences.getBool(_isLoggedInKey) ?? false; } + static Future getUserName() async { + final preferences = await SharedPreferences.getInstance(); + return preferences.getString(_userNameKey) ?? 'Ibu Sulastri'; + } + + static Future getUserEmail() async { + final preferences = await SharedPreferences.getInstance(); + return preferences.getString(_userEmailKey) ?? + 'sulastri.aritanto10@gmail.com'; + } + static Future logout() async { final preferences = await SharedPreferences.getInstance(); await preferences.remove(_isLoggedInKey); diff --git a/lib/services/ml_service.dart b/lib/services/ml_service.dart index 94ac8ff..2940831 100644 --- a/lib/services/ml_service.dart +++ b/lib/services/ml_service.dart @@ -1,11 +1,12 @@ import 'package:http/http.dart' as http; +import 'dart:async'; import 'dart:convert'; class MLService { // API URL - Change based on environment // Untuk emulator Android: 10.0.2.2 // Untuk device fisik: 192.168.x.x atau 127.0.0.1 kalau local - static const String baseUrl = 'http://192.168.18.30:5000'; + static const String baseUrl = 'http://192.168.1.16:5000'; static const int timeoutSeconds = 30; @@ -33,6 +34,92 @@ class MLService { } return {'status': 'error', 'message': body['message'] ?? 'Login gagal'}; + } on TimeoutException { + return { + 'status': 'error', + 'message': + 'Server tidak merespons. Pastikan Flask aktif dan IP/port API sudah benar.', + }; + } catch (e) { + return { + 'status': 'error', + 'message': 'Tidak dapat terhubung ke server: $e', + }; + } + } + + static Future> sendForgotPasswordOtp({ + required String usernameOrEmail, + }) async { + return _postJson('/api/forgot-password/send-otp', { + 'email': usernameOrEmail, + 'username': usernameOrEmail, + }, fallbackMessage: 'Gagal membuat OTP'); + } + + static Future> verifyForgotPasswordOtp({ + required String usernameOrEmail, + required String otp, + }) async { + return _postJson('/api/forgot-password/verify-otp', { + 'email': usernameOrEmail, + 'username': usernameOrEmail, + 'otp': otp, + }, fallbackMessage: 'Gagal verifikasi OTP'); + } + + static Future> resetPasswordWithOtp({ + required String usernameOrEmail, + required String otp, + required String newPassword, + }) async { + return _postJson('/api/forgot-password/reset', { + 'email': usernameOrEmail, + 'username': usernameOrEmail, + 'otp': otp, + 'new_password': newPassword, + }, fallbackMessage: 'Gagal reset password'); + } + + static Future> changePassword({ + required String usernameOrEmail, + required String currentPassword, + required String newPassword, + }) async { + return _postJson('/api/change-password', { + 'email': usernameOrEmail, + 'username': usernameOrEmail, + 'current_password': currentPassword, + 'new_password': newPassword, + }, fallbackMessage: 'Gagal mengubah password'); + } + + static Future> _postJson( + String path, + Map payload, { + required String fallbackMessage, + }) async { + try { + final response = await http + .post( + Uri.parse('$baseUrl$path'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + .timeout(Duration(seconds: timeoutSeconds)); + + final body = jsonDecode(response.body) as Map; + if (response.statusCode >= 200 && response.statusCode < 300) { + return body; + } + + return {'status': 'error', 'message': body['message'] ?? fallbackMessage}; + } on TimeoutException { + return { + 'status': 'error', + 'message': + 'Server tidak merespons. Pastikan Flask aktif dan IP/port API sudah benar.', + }; } catch (e) { return { 'status': 'error', diff --git a/lib/widgets/password_reset_dialog.dart b/lib/widgets/password_reset_dialog.dart new file mode 100644 index 0000000..15b1ff4 --- /dev/null +++ b/lib/widgets/password_reset_dialog.dart @@ -0,0 +1,250 @@ +import 'package:finalproject/services/ml_service.dart'; +import 'package:finalproject/theme/colors.dart'; +import 'package:finalproject/theme/text_styles.dart'; +import 'package:flutter/material.dart'; + +Future showPasswordResetDialog( + BuildContext context, { + String initialAccount = '', + bool lockAccountField = false, +}) async { + final accountController = TextEditingController(text: initialAccount.trim()); + final otpController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + + int step = 0; + bool isLoading = false; + bool resetSuccess = false; + String? maskedEmail; + + void showMessage(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: + isError ? AppColors.statusError : AppColors.primaryBrown, + ), + ); + } + + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + Future sendOtp() async { + final account = accountController.text.trim(); + if (account.isEmpty) { + showMessage('Email atau username wajib diisi', isError: true); + return; + } + + setDialogState(() => isLoading = true); + final result = await MLService.sendForgotPasswordOtp( + usernameOrEmail: account, + ); + if (!context.mounted || !dialogContext.mounted) return; + + final success = result['status'] == 'success'; + setDialogState(() { + isLoading = false; + if (success) { + step = 1; + final data = result['data']; + maskedEmail = data is Map ? data['email']?.toString() : null; + } + }); + + showMessage( + success + ? 'OTP berhasil dikirim ke email' + : result['message']?.toString() ?? 'Gagal membuat OTP', + isError: !success, + ); + } + + Future resetPassword() async { + final account = accountController.text.trim(); + final otp = otpController.text.trim(); + final newPassword = newPasswordController.text; + final confirmPassword = confirmPasswordController.text; + + if (otp.isEmpty || newPassword.isEmpty || confirmPassword.isEmpty) { + showMessage('OTP dan password baru wajib diisi', isError: true); + return; + } + + if (newPassword.length < 6) { + showMessage('Password baru minimal 6 karakter', isError: true); + return; + } + + if (newPassword != confirmPassword) { + showMessage('Konfirmasi password tidak sama', isError: true); + return; + } + + setDialogState(() => isLoading = true); + final verifyResult = await MLService.verifyForgotPasswordOtp( + usernameOrEmail: account, + otp: otp, + ); + if (!context.mounted || !dialogContext.mounted) return; + + if (verifyResult['status'] != 'success') { + setDialogState(() => isLoading = false); + showMessage( + verifyResult['message']?.toString() ?? + 'OTP salah atau sudah kedaluwarsa', + isError: true, + ); + return; + } + + final resetResult = await MLService.resetPasswordWithOtp( + usernameOrEmail: account, + otp: otp, + newPassword: newPassword, + ); + if (!context.mounted || !dialogContext.mounted) return; + + final success = resetResult['status'] == 'success'; + setDialogState(() => isLoading = false); + + showMessage( + success + ? 'Password berhasil diperbarui.' + : resetResult['message']?.toString() ?? + 'Gagal reset password', + isError: !success, + ); + + if (success) { + resetSuccess = true; + FocusScope.of(dialogContext).unfocus(); + Navigator.of(dialogContext).pop(); + } + } + + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Text( + step == 0 ? 'Lupa Password' : 'Reset Password', + style: AppTextStyles.headlineSmall.copyWith( + color: AppColors.textPrimary, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: accountController, + enabled: !isLoading && step == 0 && !lockAccountField, + decoration: const InputDecoration( + labelText: 'Email atau username', + prefixIcon: Icon(Icons.email_outlined), + ), + ), + if (step == 1) ...[ + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.bgLight, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.grey300), + ), + child: Text( + maskedEmail == null + ? 'Kode OTP telah dikirim ke email akun Anda.' + : 'Kode OTP telah dikirim ke $maskedEmail.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: otpController, + enabled: !isLoading, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Kode OTP', + prefixIcon: Icon(Icons.pin_outlined), + ), + ), + const SizedBox(height: 12), + TextField( + controller: newPasswordController, + enabled: !isLoading, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password baru', + prefixIcon: Icon(Icons.lock_outline), + ), + ), + const SizedBox(height: 12), + TextField( + controller: confirmPasswordController, + enabled: !isLoading, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Konfirmasi password', + prefixIcon: Icon(Icons.lock_reset_outlined), + ), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: + isLoading ? null : () => Navigator.of(dialogContext).pop(), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: + isLoading + ? null + : step == 0 + ? sendOtp + : resetPassword, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBrown, + foregroundColor: Colors.white, + ), + child: + isLoading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(step == 0 ? 'Kirim OTP' : 'Simpan'), + ), + ], + ); + }, + ); + }, + ); + + await Future.delayed(const Duration(milliseconds: 300)); + accountController.dispose(); + otpController.dispose(); + newPasswordController.dispose(); + confirmPasswordController.dispose(); + return resetSuccess; +} diff --git a/ml_model/app.py b/ml_model/app.py index 2a732ce..26ba6c4 100644 --- a/ml_model/app.py +++ b/ml_model/app.py @@ -5,16 +5,45 @@ Menggunakan Random Forest Model from flask import Flask, request, jsonify from flask_cors import CORS +from email.message import EmailMessage import joblib import pandas as pd import numpy as np import logging import math import hashlib -from datetime import datetime +import secrets +import os +import smtplib +from datetime import datetime, timedelta import mysql.connector from mysql.connector import Error + +def load_env_file(): + """Load simple KEY=VALUE pairs from ml_model/.env if present.""" + env_path = os.path.join(os.path.dirname(__file__), '.env') + if not os.path.exists(env_path): + return + + try: + with open(env_path, 'r', encoding='utf-8') as env_file: + for raw_line in env_file: + line = raw_line.strip() + if not line or line.startswith('#') or '=' not in line: + continue + + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key: + os.environ[key] = value + except OSError as e: + print(f"Failed to load .env file: {e}") + + +load_env_file() + # Configure logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -30,6 +59,14 @@ DB_USER = 'root' DB_PASSWORD = '' # Ganti dengan password MySQL Anda jika ada DB_NAME = 'prediksi_stok_db' +# Email/SMTP configuration for OTP delivery. +# For Gmail, use an App Password, not the regular Gmail password. +SMTP_HOST = os.getenv('SMTP_HOST', 'smtp.gmail.com') +SMTP_PORT = int(os.getenv('SMTP_PORT', '587')) +SMTP_EMAIL = os.getenv('SMTP_EMAIL', '') +SMTP_PASSWORD = os.getenv('SMTP_APP_PASSWORD', '').replace(' ', '') +SMTP_SENDER_NAME = os.getenv('SMTP_SENDER_NAME', 'Tobaku Sulastri') + # Table names (use backticks for names with spaces) TRANSACTIONS_TABLE = "`Stock In`" @@ -106,6 +143,48 @@ def verify_login_password(input_password: str, stored_password: str) -> bool: hashed_input = hashlib.sha256(input_password.encode('utf-8')).hexdigest() return hashed_input == stored_password +def hash_password(password: str) -> str: + return hashlib.sha256(password.encode('utf-8')).hexdigest() + +def send_otp_email(recipient_email: str, otp_code: str) -> None: + if not SMTP_EMAIL or not SMTP_PASSWORD: + raise RuntimeError( + 'SMTP_EMAIL dan SMTP_APP_PASSWORD belum dikonfigurasi' + ) + + message = EmailMessage() + message['Subject'] = 'Kode OTP Reset Password Tobaku Sulastri' + message['From'] = f'{SMTP_SENDER_NAME} <{SMTP_EMAIL}>' + message['To'] = recipient_email + message.set_content( + f"""Halo, + +Kode OTP untuk reset password aplikasi Tobaku Sulastri adalah: + +{otp_code} + +Kode ini berlaku selama 10 menit. Abaikan email ini jika Anda tidak meminta reset password. + +Terima kasih, +Tobaku Sulastri +""" + ) + + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as server: + server.starttls() + server.login(SMTP_EMAIL, SMTP_PASSWORD) + server.send_message(message) + +def mask_email(email: str) -> str: + if '@' not in email: + return email + name, domain = email.split('@', 1) + if len(name) <= 2: + masked_name = name[0] + '*' + else: + masked_name = name[:2] + ('*' * max(2, len(name) - 2)) + return f'{masked_name}@{domain}' + def ensure_prediction_needs_column(connection, table_name: str = 'predictions') -> str | None: needs_col = get_existing_column( connection, @@ -544,6 +623,324 @@ def login(): }), 500 +@app.route('/api/forgot-password/send-otp', methods=['POST']) +def send_forgot_password_otp(): + """Create OTP for password reset and send it to the account email.""" + try: + data = request.json or {} + username_or_email = (data.get('email') or data.get('username') or '').strip() + + if not username_or_email: + return jsonify({ + 'status': 'error', + 'message': 'Email/username wajib diisi' + }), 400 + + connection = get_db_connection() + if connection is None: + return jsonify({ + 'status': 'error', + 'message': 'Tidak dapat terhubung ke database' + }), 500 + + cursor = connection.cursor(dictionary=True) + cursor.execute(""" + SELECT id, name, email, username + FROM login + WHERE email = %s OR username = %s + LIMIT 1 + """, (username_or_email, username_or_email)) + user = cursor.fetchone() + cursor.close() + connection.close() + + if user is None: + return jsonify({ + 'status': 'error', + 'message': 'Akun tidak ditemukan' + }), 404 + + otp_code = f"{secrets.randbelow(1000000):06d}" + expires_at = datetime.now() + timedelta(minutes=10) + + send_otp_email(user['email'], otp_code) + + connection = get_db_connection() + if connection is None: + return jsonify({ + 'status': 'error', + 'message': 'Tidak dapat terhubung ke database' + }), 500 + + cursor = connection.cursor(dictionary=True) + cursor.execute(""" + INSERT INTO password_reset_otps (login_id, email, otp_code, expires_at) + VALUES (%s, %s, %s, %s) + """, (user['id'], user['email'], otp_code, expires_at)) + + connection.commit() + cursor.close() + connection.close() + + return jsonify({ + 'status': 'success', + 'message': 'Kode OTP berhasil dikirim ke email. Berlaku selama 10 menit.', + 'data': { + 'email': mask_email(user['email']) + } + }), 200 + except RuntimeError as e: + logger.error(f"Send OTP configuration error: {e}") + return jsonify({ + 'status': 'error', + 'message': str(e) + }), 500 + except smtplib.SMTPAuthenticationError as e: + logger.error(f"Send OTP SMTP authentication error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Gagal login ke Gmail. Gunakan App Password Gmail, bukan password email biasa.' + }), 500 + except smtplib.SMTPException as e: + logger.error(f"Send OTP SMTP error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Gagal mengirim OTP ke email. Periksa konfigurasi SMTP dan koneksi internet.' + }), 500 + except Error as e: + logger.error(f"Send OTP database error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Tabel OTP belum tersedia atau database bermasalah' + }), 500 + except Exception as e: + logger.error(f"Send OTP error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Terjadi kesalahan saat membuat OTP' + }), 500 + + +@app.route('/api/change-password', methods=['POST']) +def change_password(): + """Change password for a logged-in account using the current password.""" + try: + data = request.json or {} + username_or_email = (data.get('email') or data.get('username') or '').strip() + current_password = data.get('current_password') or '' + new_password = data.get('new_password') or '' + + if not username_or_email or not current_password or not new_password: + return jsonify({ + 'status': 'error', + 'message': 'Email/username, password lama, dan password baru wajib diisi' + }), 400 + + if len(new_password) < 6: + return jsonify({ + 'status': 'error', + 'message': 'Password baru minimal 6 karakter' + }), 400 + + connection = get_db_connection() + if connection is None: + return jsonify({ + 'status': 'error', + 'message': 'Tidak dapat terhubung ke database' + }), 500 + + cursor = connection.cursor(dictionary=True) + cursor.execute(""" + SELECT id, password + FROM login + WHERE email = %s OR username = %s + LIMIT 1 + """, (username_or_email, username_or_email)) + user = cursor.fetchone() + + if user is None: + cursor.close() + connection.close() + return jsonify({ + 'status': 'error', + 'message': 'Akun tidak ditemukan' + }), 404 + + if not verify_login_password(current_password, user['password']): + cursor.close() + connection.close() + return jsonify({ + 'status': 'error', + 'message': 'Password lama tidak sesuai' + }), 400 + + cursor.execute( + "UPDATE login SET password = %s WHERE id = %s", + (hash_password(new_password), user['id']) + ) + connection.commit() + cursor.close() + connection.close() + + return jsonify({ + 'status': 'success', + 'message': 'Password berhasil diperbarui' + }), 200 + except Error as e: + logger.error(f"Change password database error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Database bermasalah saat mengubah password' + }), 500 + except Exception as e: + logger.error(f"Change password error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Terjadi kesalahan saat mengubah password' + }), 500 + + +@app.route('/api/forgot-password/verify-otp', methods=['POST']) +def verify_forgot_password_otp(): + """Verify OTP before allowing password reset.""" + try: + data = request.json or {} + username_or_email = (data.get('email') or data.get('username') or '').strip() + otp_code = (data.get('otp') or '').strip() + + if not username_or_email or not otp_code: + return jsonify({ + 'status': 'error', + 'message': 'Email/username dan OTP wajib diisi' + }), 400 + + connection = get_db_connection() + if connection is None: + return jsonify({ + 'status': 'error', + 'message': 'Tidak dapat terhubung ke database' + }), 500 + + cursor = connection.cursor(dictionary=True) + cursor.execute(""" + SELECT o.id + FROM password_reset_otps o + JOIN login l ON l.id = o.login_id + WHERE (l.email = %s OR l.username = %s) + AND o.otp_code = %s + AND o.is_used = 0 + AND o.expires_at >= NOW() + ORDER BY o.created_at DESC + LIMIT 1 + """, (username_or_email, username_or_email, otp_code)) + otp = cursor.fetchone() + cursor.close() + connection.close() + + if otp is None: + return jsonify({ + 'status': 'error', + 'message': 'OTP salah atau sudah kedaluwarsa' + }), 400 + + return jsonify({ + 'status': 'success', + 'message': 'OTP valid' + }), 200 + except Error as e: + logger.error(f"Verify OTP database error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Tabel OTP belum tersedia atau database bermasalah' + }), 500 + except Exception as e: + logger.error(f"Verify OTP error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Terjadi kesalahan saat verifikasi OTP' + }), 500 + + +@app.route('/api/forgot-password/reset', methods=['POST']) +def reset_password_with_otp(): + """Reset account password after OTP verification.""" + try: + data = request.json or {} + username_or_email = (data.get('email') or data.get('username') or '').strip() + otp_code = (data.get('otp') or '').strip() + new_password = data.get('new_password') or '' + + if not username_or_email or not otp_code or not new_password: + return jsonify({ + 'status': 'error', + 'message': 'Email/username, OTP, dan password baru wajib diisi' + }), 400 + + if len(new_password) < 6: + return jsonify({ + 'status': 'error', + 'message': 'Password baru minimal 6 karakter' + }), 400 + + connection = get_db_connection() + if connection is None: + return jsonify({ + 'status': 'error', + 'message': 'Tidak dapat terhubung ke database' + }), 500 + + cursor = connection.cursor(dictionary=True) + cursor.execute(""" + SELECT o.id AS otp_id, l.id AS login_id + FROM password_reset_otps o + JOIN login l ON l.id = o.login_id + WHERE (l.email = %s OR l.username = %s) + AND o.otp_code = %s + AND o.is_used = 0 + AND o.expires_at >= NOW() + ORDER BY o.created_at DESC + LIMIT 1 + """, (username_or_email, username_or_email, otp_code)) + otp = cursor.fetchone() + + if otp is None: + cursor.close() + connection.close() + return jsonify({ + 'status': 'error', + 'message': 'OTP salah atau sudah kedaluwarsa' + }), 400 + + cursor.execute( + "UPDATE login SET password = %s WHERE id = %s", + (hash_password(new_password), otp['login_id']) + ) + cursor.execute( + "UPDATE password_reset_otps SET is_used = 1 WHERE id = %s", + (otp['otp_id'],) + ) + connection.commit() + cursor.close() + connection.close() + + return jsonify({ + 'status': 'success', + 'message': 'Password berhasil diperbarui' + }), 200 + except Error as e: + logger.error(f"Reset password database error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Tabel OTP belum tersedia atau database bermasalah' + }), 500 + except Exception as e: + logger.error(f"Reset password error: {e}") + return jsonify({ + 'status': 'error', + 'message': 'Terjadi kesalahan saat reset password' + }), 500 + + @app.route('/prediksi', methods=['POST']) def prediksi(): """ diff --git a/ml_model/database_setup.py b/ml_model/database_setup.py index e48a54d..521b914 100644 --- a/ml_model/database_setup.py +++ b/ml_model/database_setup.py @@ -115,6 +115,21 @@ def create_tables(): """) logger.info("Login table created successfully") + # Password reset OTP table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS password_reset_otps ( + id INT PRIMARY KEY AUTO_INCREMENT, + login_id INT NOT NULL, + email VARCHAR(100) NOT NULL, + otp_code VARCHAR(10) NOT NULL, + expires_at DATETIME NOT NULL, + is_used TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (login_id) REFERENCES login(id) ON DELETE CASCADE + ) + """) + logger.info("Password reset OTP table created successfully") + connection.commit() cursor.close() connection.close() @@ -160,8 +175,9 @@ def insert_default_login(): VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE name = VALUES(name), + email = VALUES(email), username = VALUES(username) - """, ('Ibu Sulastri', 'admin@sulastri.com', 'admin', 'password')) + """, ('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password')) connection.commit() logger.info("Default login account ready") @@ -214,7 +230,7 @@ def main(): print() print("=" * 60) - print("✅ Database setup completed successfully!") + print("Database setup completed successfully!") print("=" * 60) if __name__ == "__main__": diff --git a/ml_model/setup_database.sql b/ml_model/setup_database.sql index 8fe6593..615a933 100644 --- a/ml_model/setup_database.sql +++ b/ml_model/setup_database.sql @@ -69,6 +69,18 @@ CREATE TABLE IF NOT EXISTS login ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- Create Password Reset OTP Table +CREATE TABLE IF NOT EXISTS password_reset_otps ( + id INT AUTO_INCREMENT PRIMARY KEY, + login_id INT NOT NULL, + email VARCHAR(100) NOT NULL, + otp_code VARCHAR(10) NOT NULL, + expires_at DATETIME NOT NULL, + is_used TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (login_id) REFERENCES login(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Create Recipes Table CREATE TABLE IF NOT EXISTS recipes ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -100,9 +112,10 @@ INSERT INTO products (name, category, product_type, unit, price, current_stock) -- Insert Default Login Account INSERT INTO login (name, email, username, password) VALUES -('Ibu Sulastri', 'admin@sulastri.com', 'admin', 'password') +('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password') ON DUPLICATE KEY UPDATE name = VALUES(name), + email = VALUES(email), username = VALUES(username); -- Insert Sample Recipes