Skip to content

Add Password recovery with OTP #130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 146 additions & 10 deletions lib/src/components/supa_email_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,15 @@ class SupaEmailAuth extends StatefulWidget {
final Widget? prefixIconEmail;
final Widget? prefixIconPassword;

/// Icon or custom prefix widget for OTP input field
final Widget? prefixIconOtp;

/// Whether the confirm password field should be displayed
final bool showConfirmPasswordField;

/// Whether to use OTP for password recovery instead of magic link
final bool useOtpForPasswordRecovery;

/// {@macro supa_email_auth}
const SupaEmailAuth({
super.key,
Expand All @@ -235,7 +241,9 @@ class SupaEmailAuth extends StatefulWidget {
this.isInitiallySigningIn = true,
this.prefixIconEmail = const Icon(Icons.email),
this.prefixIconPassword = const Icon(Icons.lock),
this.prefixIconOtp = const Icon(Icons.security),
this.showConfirmPasswordField = false,
this.useOtpForPasswordRecovery = false,
});

@override
Expand All @@ -258,6 +266,18 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
/// Focus node for email field
final FocusNode _emailFocusNode = FocusNode();

/// Controller for OTP input field
final _otpController = TextEditingController();

/// Controller for new password input field
final _newPasswordController = TextEditingController();

/// Controller for confirm new password input field
final _confirmNewPasswordController = TextEditingController();

/// Whether the user is entering OTP code
bool _isEnteringOtp = false;

@override
void initState() {
super.initState();
Expand All @@ -277,6 +297,9 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
_emailController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_otpController.dispose();
_newPasswordController.dispose();
_confirmNewPasswordController.dispose();
for (final controller in _metadataControllers.values) {
if (controller is TextEditingController) {
controller.dispose();
Expand Down Expand Up @@ -501,10 +524,59 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
],
if (_isSigningIn && _isRecoveringPassword) ...[
spacer(16),
ElevatedButton(
onPressed: _passwordRecovery,
child: Text(localization.sendPasswordReset),
),
if (!_isEnteringOtp) ...[
ElevatedButton(
onPressed: _passwordRecovery,
child: Text(localization.sendPasswordReset),
),
] else ...[
TextFormField(
controller: _otpController,
decoration: InputDecoration(
label: Text(localization.enterOtpCode),
prefixIcon: widget.prefixIconOtp,
),
keyboardType: TextInputType.number,
),
spacer(16),
TextFormField(
controller: _newPasswordController,
decoration: InputDecoration(
label: Text(localization.enterNewPassword),
prefixIcon: widget.prefixIconPassword,
),
obscureText: true,
validator: widget.passwordValidator ??
(value) {
if (value == null ||
value.isEmpty ||
value.length < 6) {
return localization.passwordLengthError;
}
return null;
},
),
spacer(16),
TextFormField(
controller: _confirmNewPasswordController,
decoration: InputDecoration(
label: Text(localization.confirmPassword),
prefixIcon: widget.prefixIconPassword,
),
obscureText: true,
validator: (value) {
if (value != _newPasswordController.text) {
return localization.confirmPasswordError;
}
return null;
},
),
spacer(16),
ElevatedButton(
onPressed: _verifyOtpAndResetPassword,
child: Text(localization.changePassword),
),
],
spacer(16),
TextButton(
onPressed: () {
Expand Down Expand Up @@ -595,16 +667,80 @@ class _SupaEmailAuthState extends State<SupaEmailAuth> {
});

final email = _emailController.text.trim();
await supabase.auth.resetPasswordForEmail(
email,
redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo,

if (widget.useOtpForPasswordRecovery) {
await supabase.auth.resetPasswordForEmail(
email,
redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo,
);
if (!mounted) return;
context.showSnackBar(widget.localization.passwordResetSent);
setState(() {
_isEnteringOtp = true;
});
} else {
await supabase.auth.resetPasswordForEmail(
email,
redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo,
);
widget.onPasswordResetEmailSent?.call();
if (!mounted) return;
context.showSnackBar(widget.localization.passwordResetSent);
setState(() {
_isRecoveringPassword = false;
});
}
} on AuthException catch (error) {
widget.onError?.call(error);
} catch (error) {
widget.onError?.call(error);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}

void _verifyOtpAndResetPassword() async {
try {
if (!_formKey.currentState!.validate()) {
return;
}

setState(() {
_isLoading = true;
});

try {
await supabase.auth.verifyOTP(
type: OtpType.recovery,
email: _emailController.text.trim(),
token: _otpController.text.trim(),
);
} on AuthException catch (error) {
if (error.code == 'otp_expired') {
if (!mounted) return;
context.showErrorSnackBar(widget.localization.otpCodeError);
return;
} else if (error.code == 'otp_disabled') {
if (!mounted) return;
context.showErrorSnackBar(widget.localization.otpDisabledError);
return;
}
rethrow;
}

await supabase.auth.updateUser(
UserAttributes(password: _newPasswordController.text),
);
widget.onPasswordResetEmailSent?.call();
// FIX use_build_context_synchronously

if (!mounted) return;
context.showSnackBar(widget.localization.passwordResetSent);
context.showSnackBar(widget.localization.passwordChangedSuccess);
setState(() {
_isRecoveringPassword = false;
_isEnteringOtp = false;
});
} on AuthException catch (error) {
widget.onError?.call(error);
Expand Down
12 changes: 12 additions & 0 deletions lib/src/localizations/supa_email_auth_localization.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ class SupaEmailAuthLocalization {
final String requiredFieldError;
final String confirmPasswordError;
final String confirmPassword;
final String enterOtpCode;
final String enterNewPassword;
final String changePassword;
final String passwordChangedSuccess;
final String otpCodeError;
final String otpDisabledError;

const SupaEmailAuthLocalization({
this.enterEmail = 'Enter your email',
Expand All @@ -34,5 +40,11 @@ class SupaEmailAuthLocalization {
this.requiredFieldError = 'This field is required',
this.confirmPasswordError = 'Passwords do not match',
this.confirmPassword = 'Confirm Password',
this.enterOtpCode = 'Enter OTP code',
this.enterNewPassword = 'Enter new password',
this.changePassword = 'Change Password',
this.passwordChangedSuccess = 'Password successfully updated',
this.otpCodeError = 'Invalid OTP code',
this.otpDisabledError = 'OTP disabled',
});
}
Loading