Skip to content

Commit abb6fe8

Browse files
committed
Add Password reset with otp
1 parent 99513e9 commit abb6fe8

File tree

2 files changed

+158
-10
lines changed

2 files changed

+158
-10
lines changed

lib/src/components/supa_email_auth.dart

Lines changed: 146 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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);

lib/src/localizations/supa_email_auth_localization.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ class SupaEmailAuthLocalization {
1515
final String requiredFieldError;
1616
final String confirmPasswordError;
1717
final String confirmPassword;
18+
final String enterOtpCode;
19+
final String enterNewPassword;
20+
final String changePassword;
21+
final String passwordChangedSuccess;
22+
final String otpCodeError;
23+
final String otpDisabledError;
1824

1925
const SupaEmailAuthLocalization({
2026
this.enterEmail = 'Enter your email',
@@ -34,5 +40,11 @@ class SupaEmailAuthLocalization {
3440
this.requiredFieldError = 'This field is required',
3541
this.confirmPasswordError = 'Passwords do not match',
3642
this.confirmPassword = 'Confirm Password',
43+
this.enterOtpCode = 'Enter OTP code',
44+
this.enterNewPassword = 'Enter new password',
45+
this.changePassword = 'Change Password',
46+
this.passwordChangedSuccess = 'Password successfully updated',
47+
this.otpCodeError = 'Invalid OTP code',
48+
this.otpDisabledError = 'OTP disabled',
3749
});
3850
}

0 commit comments

Comments
 (0)