@@ -214,9 +214,15 @@ class SupaEmailAuth extends StatefulWidget {
214
214
final Widget ? prefixIconEmail;
215
215
final Widget ? prefixIconPassword;
216
216
217
+ /// Icon or custom prefix widget for OTP input field
218
+ final Widget ? prefixIconOtp;
219
+
217
220
/// Whether the confirm password field should be displayed
218
221
final bool showConfirmPasswordField;
219
222
223
+ /// Whether to use OTP for password recovery instead of magic link
224
+ final bool useOtpForPasswordRecovery;
225
+
220
226
/// {@macro supa_email_auth}
221
227
const SupaEmailAuth ({
222
228
super .key,
@@ -235,7 +241,9 @@ class SupaEmailAuth extends StatefulWidget {
235
241
this .isInitiallySigningIn = true ,
236
242
this .prefixIconEmail = const Icon (Icons .email),
237
243
this .prefixIconPassword = const Icon (Icons .lock),
244
+ this .prefixIconOtp = const Icon (Icons .security),
238
245
this .showConfirmPasswordField = false ,
246
+ this .useOtpForPasswordRecovery = false ,
239
247
});
240
248
241
249
@override
@@ -258,6 +266,18 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
258
266
/// Focus node for email field
259
267
final FocusNode _emailFocusNode = FocusNode ();
260
268
269
+ /// Controller for OTP input field
270
+ final _otpController = TextEditingController ();
271
+
272
+ /// Controller for new password input field
273
+ final _newPasswordController = TextEditingController ();
274
+
275
+ /// Controller for confirm new password input field
276
+ final _confirmNewPasswordController = TextEditingController ();
277
+
278
+ /// Whether the user is entering OTP code
279
+ bool _isEnteringOtp = false ;
280
+
261
281
@override
262
282
void initState () {
263
283
super .initState ();
@@ -277,6 +297,9 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
277
297
_emailController.dispose ();
278
298
_passwordController.dispose ();
279
299
_confirmPasswordController.dispose ();
300
+ _otpController.dispose ();
301
+ _newPasswordController.dispose ();
302
+ _confirmNewPasswordController.dispose ();
280
303
for (final controller in _metadataControllers.values) {
281
304
if (controller is TextEditingController ) {
282
305
controller.dispose ();
@@ -501,10 +524,59 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
501
524
],
502
525
if (_isSigningIn && _isRecoveringPassword) ...[
503
526
spacer (16 ),
504
- ElevatedButton (
505
- onPressed: _passwordRecovery,
506
- child: Text (localization.sendPasswordReset),
507
- ),
527
+ if (! _isEnteringOtp) ...[
528
+ ElevatedButton (
529
+ onPressed: _passwordRecovery,
530
+ child: Text (localization.sendPasswordReset),
531
+ ),
532
+ ] else ...[
533
+ TextFormField (
534
+ controller: _otpController,
535
+ decoration: InputDecoration (
536
+ label: Text (localization.enterOtpCode),
537
+ prefixIcon: widget.prefixIconOtp,
538
+ ),
539
+ keyboardType: TextInputType .number,
540
+ ),
541
+ spacer (16 ),
542
+ TextFormField (
543
+ controller: _newPasswordController,
544
+ decoration: InputDecoration (
545
+ label: Text (localization.enterNewPassword),
546
+ prefixIcon: widget.prefixIconPassword,
547
+ ),
548
+ obscureText: true ,
549
+ validator: widget.passwordValidator ??
550
+ (value) {
551
+ if (value == null ||
552
+ value.isEmpty ||
553
+ value.length < 6 ) {
554
+ return localization.passwordLengthError;
555
+ }
556
+ return null ;
557
+ },
558
+ ),
559
+ spacer (16 ),
560
+ TextFormField (
561
+ controller: _confirmNewPasswordController,
562
+ decoration: InputDecoration (
563
+ label: Text (localization.confirmPassword),
564
+ prefixIcon: widget.prefixIconPassword,
565
+ ),
566
+ obscureText: true ,
567
+ validator: (value) {
568
+ if (value != _newPasswordController.text) {
569
+ return localization.confirmPasswordError;
570
+ }
571
+ return null ;
572
+ },
573
+ ),
574
+ spacer (16 ),
575
+ ElevatedButton (
576
+ onPressed: _verifyOtpAndResetPassword,
577
+ child: Text (localization.changePassword),
578
+ ),
579
+ ],
508
580
spacer (16 ),
509
581
TextButton (
510
582
onPressed: () {
@@ -595,16 +667,80 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
595
667
});
596
668
597
669
final email = _emailController.text.trim ();
598
- await supabase.auth.resetPasswordForEmail (
599
- email,
600
- redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo,
670
+
671
+ if (widget.useOtpForPasswordRecovery) {
672
+ await supabase.auth.resetPasswordForEmail (
673
+ email,
674
+ redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo,
675
+ );
676
+ if (! mounted) return ;
677
+ context.showSnackBar (widget.localization.passwordResetSent);
678
+ setState (() {
679
+ _isEnteringOtp = true ;
680
+ });
681
+ } else {
682
+ await supabase.auth.resetPasswordForEmail (
683
+ email,
684
+ redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo,
685
+ );
686
+ widget.onPasswordResetEmailSent? .call ();
687
+ if (! mounted) return ;
688
+ context.showSnackBar (widget.localization.passwordResetSent);
689
+ setState (() {
690
+ _isRecoveringPassword = false ;
691
+ });
692
+ }
693
+ } on AuthException catch (error) {
694
+ widget.onError? .call (error);
695
+ } catch (error) {
696
+ widget.onError? .call (error);
697
+ } finally {
698
+ if (mounted) {
699
+ setState (() {
700
+ _isLoading = false ;
701
+ });
702
+ }
703
+ }
704
+ }
705
+
706
+ void _verifyOtpAndResetPassword () async {
707
+ try {
708
+ if (! _formKey.currentState! .validate ()) {
709
+ return ;
710
+ }
711
+
712
+ setState (() {
713
+ _isLoading = true ;
714
+ });
715
+
716
+ try {
717
+ await supabase.auth.verifyOTP (
718
+ type: OtpType .recovery,
719
+ email: _emailController.text.trim (),
720
+ token: _otpController.text.trim (),
721
+ );
722
+ } on AuthException catch (error) {
723
+ if (error.code == 'otp_expired' ) {
724
+ if (! mounted) return ;
725
+ context.showErrorSnackBar (widget.localization.otpCodeError);
726
+ return ;
727
+ } else if (error.code == 'otp_disabled' ) {
728
+ if (! mounted) return ;
729
+ context.showErrorSnackBar (widget.localization.otpDisabledError);
730
+ return ;
731
+ }
732
+ rethrow ;
733
+ }
734
+
735
+ await supabase.auth.updateUser (
736
+ UserAttributes (password: _newPasswordController.text),
601
737
);
602
- widget.onPasswordResetEmailSent? .call ();
603
- // FIX use_build_context_synchronously
738
+
604
739
if (! mounted) return ;
605
- context.showSnackBar (widget.localization.passwordResetSent );
740
+ context.showSnackBar (widget.localization.passwordChangedSuccess );
606
741
setState (() {
607
742
_isRecoveringPassword = false ;
743
+ _isEnteringOtp = false ;
608
744
});
609
745
} on AuthException catch (error) {
610
746
widget.onError? .call (error);
0 commit comments