@@ -214,9 +214,15 @@ class SupaEmailAuth extends StatefulWidget {
214214 final Widget ? prefixIconEmail;
215215 final Widget ? prefixIconPassword;
216216
217+ /// Icon or custom prefix widget for OTP input field
218+ final Widget ? prefixIconOtp;
219+
217220 /// Whether the confirm password field should be displayed
218221 final bool showConfirmPasswordField;
219222
223+ /// Whether to use OTP for password recovery instead of magic link
224+ final bool useOtpForPasswordRecovery;
225+
220226 /// {@macro supa_email_auth}
221227 const SupaEmailAuth ({
222228 super .key,
@@ -235,7 +241,9 @@ class SupaEmailAuth extends StatefulWidget {
235241 this .isInitiallySigningIn = true ,
236242 this .prefixIconEmail = const Icon (Icons .email),
237243 this .prefixIconPassword = const Icon (Icons .lock),
244+ this .prefixIconOtp = const Icon (Icons .security),
238245 this .showConfirmPasswordField = false ,
246+ this .useOtpForPasswordRecovery = false ,
239247 });
240248
241249 @override
@@ -258,6 +266,18 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
258266 /// Focus node for email field
259267 final FocusNode _emailFocusNode = FocusNode ();
260268
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+
261281 @override
262282 void initState () {
263283 super .initState ();
@@ -277,6 +297,9 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
277297 _emailController.dispose ();
278298 _passwordController.dispose ();
279299 _confirmPasswordController.dispose ();
300+ _otpController.dispose ();
301+ _newPasswordController.dispose ();
302+ _confirmNewPasswordController.dispose ();
280303 for (final controller in _metadataControllers.values) {
281304 if (controller is TextEditingController ) {
282305 controller.dispose ();
@@ -501,10 +524,59 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
501524 ],
502525 if (_isSigningIn && _isRecoveringPassword) ...[
503526 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+ ],
508580 spacer (16 ),
509581 TextButton (
510582 onPressed: () {
@@ -595,16 +667,80 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
595667 });
596668
597669 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),
601737 );
602- widget.onPasswordResetEmailSent? .call ();
603- // FIX use_build_context_synchronously
738+
604739 if (! mounted) return ;
605- context.showSnackBar (widget.localization.passwordResetSent );
740+ context.showSnackBar (widget.localization.passwordChangedSuccess );
606741 setState (() {
607742 _isRecoveringPassword = false ;
743+ _isEnteringOtp = false ;
608744 });
609745 } on AuthException catch (error) {
610746 widget.onError? .call (error);
0 commit comments