diff --git a/lib/core/errors/auth_exceptions.dart b/lib/core/errors/auth_exceptions.dart index d4d3cae..87de83c 100644 --- a/lib/core/errors/auth_exceptions.dart +++ b/lib/core/errors/auth_exceptions.dart @@ -120,6 +120,19 @@ class EmailNotVerifiedException extends AuthException { ); } +/// Exception thrown when a reset token is invalid or expired +class InvalidTokenException extends AuthException { + const InvalidTokenException({ + String message = 'Reset token is invalid or has expired', + String? code, + dynamic originalError, + }) : super( + message: message, + code: code ?? 'INVALID_TOKEN', + originalError: originalError, + ); +} + /// Exception thrown when too many login attempts are made class TooManyRequestsException extends AuthException { const TooManyRequestsException({ @@ -198,6 +211,17 @@ class AuthExceptionFactory { ); } + if (lowerMessage.contains('invalid token') || + lowerMessage.contains('token expired') || + lowerMessage.contains('reset token') || + lowerMessage.contains('session has expired')) { + return InvalidTokenException( + message: _extractUserFriendlyMessage(errorMessage) ?? 'Reset token is invalid or has expired', + code: errorCode, + originalError: error, + ); + } + if (lowerMessage.contains('user not found') || lowerMessage.contains('no user found') || lowerMessage.contains('user does not exist')) { diff --git a/lib/core/utils/password_validator.dart b/lib/core/utils/password_validator.dart new file mode 100644 index 0000000..d015442 --- /dev/null +++ b/lib/core/utils/password_validator.dart @@ -0,0 +1,92 @@ +/// Utility class for password validation and strength checking +class PasswordValidator { + /// Minimum password length + static const int minLength = 8; + + /// Validates a password against security requirements + /// + /// Returns [PasswordValidationResult] with detailed validation information + static PasswordValidationResult validate(String password) { + final errors = []; + double strength = 0.0; + + // Check length + if (password.length < minLength) { + errors.add('Password must be at least $minLength characters'); + } else { + strength += 0.25; + } + + // Check for uppercase letter + if (!password.contains(RegExp(r'[A-Z]'))) { + errors.add('Password must contain at least one uppercase letter'); + } else { + strength += 0.25; + } + + // Check for lowercase letter + if (!password.contains(RegExp(r'[a-z]'))) { + errors.add('Password must contain at least one lowercase letter'); + } else { + strength += 0.25; + } + + // Check for number + if (!password.contains(RegExp(r'[0-9]'))) { + errors.add('Password must contain at least one number'); + } else { + strength += 0.25; + } + + // Bonus points for special characters + if (password.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'))) { + strength = (strength + 0.1).clamp(0.0, 1.0); + } + + // Bonus points for longer passwords + if (password.length >= 12) { + strength = (strength + 0.1).clamp(0.0, 1.0); + } + + return PasswordValidationResult( + isValid: errors.isEmpty, + errors: errors, + strength: strength, + ); + } + + /// Gets password strength text based on strength value + static String getStrengthText(double strength) { + if (strength <= 0.25) return 'Weak'; + if (strength <= 0.5) return 'Fair'; + if (strength <= 0.75) return 'Good'; + return 'Strong'; + } + + /// Gets password strength color based on strength value + static String getStrengthColor(double strength) { + if (strength <= 0.25) return 'red'; + if (strength <= 0.5) return 'orange'; + if (strength <= 0.75) return 'yellow'; + return 'green'; + } +} + +/// Result of password validation +class PasswordValidationResult { + final bool isValid; + final List errors; + final double strength; + + const PasswordValidationResult({ + required this.isValid, + required this.errors, + required this.strength, + }); + + /// Gets the strength text for this result + String get strengthText => PasswordValidator.getStrengthText(strength); + + /// Gets the strength color name for this result + String get strengthColor => PasswordValidator.getStrengthColor(strength); +} \ No newline at end of file diff --git a/lib/features/authentication/data/repositories/auth_repository_impl.dart b/lib/features/authentication/data/repositories/auth_repository_impl.dart index 1d1b694..c83ca2f 100644 --- a/lib/features/authentication/data/repositories/auth_repository_impl.dart +++ b/lib/features/authentication/data/repositories/auth_repository_impl.dart @@ -302,6 +302,33 @@ class AuthRepositoryImpl implements AuthRepository { } } + @override + Future updatePasswordFromReset(String newPassword) async { + try { + // Extract the current session to verify we have a valid reset token + final session = _supabase.auth.currentSession; + + if (session == null) { + throw const InvalidTokenException( + message: 'No valid password reset session found. Please request a new reset link.', + code: 'NO_RESET_SESSION', + ); + } + + // Update password using Supabase updateUser + await _supabase.auth.updateUser( + UserAttributes( + password: newPassword, + ), + ); + + // After successful password update, sign out to force re-authentication + await _supabase.auth.signOut(); + } catch (e) { + throw AuthExceptionFactory.fromSupabaseError(e); + } + } + /// Dispose of any active subscriptions /// /// Call this when the repository is no longer needed to prevent memory leaks diff --git a/lib/features/authentication/domain/repositories/auth_repository.dart b/lib/features/authentication/domain/repositories/auth_repository.dart index 1d1be06..c5294da 100644 --- a/lib/features/authentication/domain/repositories/auth_repository.dart +++ b/lib/features/authentication/domain/repositories/auth_repository.dart @@ -175,11 +175,26 @@ abstract class AuthRepository { /// /// Creates an anonymous user session that can be upgraded to a full account later /// - /// Returns the anonymous [AuthUser] on successful sign in + /// Returns anonymous [AuthUser] on successful sign in /// /// Throws: /// - [NetworkException] if network connection fails /// - [AuthDisabledException] if anonymous sign in is disabled /// - [AuthException] for other authentication errors Future signInAnonymously(); + + /// Updates password from password reset token + /// + /// This method is used when users click on password reset links + /// from their email. The reset token is typically extracted from + /// the deep link URL by the authentication system. + /// + /// [newPassword] - The new password to set for the user + /// + /// Throws: + /// - [InvalidTokenException] if reset token is expired or invalid + /// - [WeakPasswordException] if new password doesn't meet requirements + /// - [NetworkException] if network connection fails + /// - [AuthException] for other authentication errors + Future updatePasswordFromReset(String newPassword); } \ No newline at end of file diff --git a/lib/features/authentication/presentation/pages/update_password_page.dart b/lib/features/authentication/presentation/pages/update_password_page.dart new file mode 100644 index 0000000..84a85f6 --- /dev/null +++ b/lib/features/authentication/presentation/pages/update_password_page.dart @@ -0,0 +1,435 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../widgets/auth_button.dart'; +import '../providers/auth_provider.dart'; +import '../../../../core/errors/auth_exceptions.dart'; +import '../../../../core/utils/password_validator.dart'; + +/// Password update page for handling password reset from email links +/// +/// This page handles the password reset flow when users click on reset links +/// from their email. It validates the reset token and allows users to set +/// a new password. +class UpdatePasswordPage extends ConsumerStatefulWidget { + const UpdatePasswordPage({super.key}); + + @override + ConsumerState createState() => _UpdatePasswordPageState(); +} + +class _UpdatePasswordPageState extends ConsumerState { + final _formKey = GlobalKey(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + bool _isLoading = false; + bool _showPassword = false; + bool _showConfirmPassword = false; + String? _errorMessage; + bool _passwordUpdated = false; + + @override + void dispose() { + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _handlePasswordUpdate() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final authProvider = ref.read(authProvider.notifier); + await authProvider.updatePasswordFromReset( + _newPasswordController.text.trim(), + ); + + if (mounted) { + setState(() { + _isLoading = false; + _passwordUpdated = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _errorMessage = _mapErrorToMessage(e); + }); + } + } + } + + String _mapErrorToMessage(Object error) { + if (error is InvalidTokenException) { + return 'This password reset link has expired or is invalid. Please request a new one.'; + } else if (error is WeakPasswordException) { + return 'Password is too weak. Please choose a stronger password.'; + } else if (error is SessionExpiredException) { + return 'Password reset session expired. Please request a new reset link.'; + } else if (error is NetworkException) { + return 'Network error. Please check your connection and try again.'; + } else if (error is AuthException) { + return error.message; + } else { + return 'An unexpected error occurred. Please try again.'; + } + } + + void _navigateToLogin() { + Navigator.of(context).pushReplacementNamed('/login'); + } + + bool _isPasswordValid() { + final password = _newPasswordController.text; + return password.length >= 8 && + password.contains(RegExp(r'[A-Z]')) && + password.contains(RegExp(r'[a-z]')) && + password.contains(RegExp(r'[0-9]')); + } + + String? _validatePassword(String? value) { + if (value == null || value.isEmpty) { + return 'Please enter a new password'; + } + + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + + if (!value.contains(RegExp(r'[A-Z]'))) { + return 'Password must contain at least one uppercase letter'; + } + + if (!value.contains(RegExp(r'[a-z]'))) { + return 'Password must contain at least one lowercase letter'; + } + + if (!value.contains(RegExp(r'[0-9]'))) { + return 'Password must contain at least one number'; + } + + return null; + } + + String? _validateConfirmPassword(String? value) { + if (value == null || value.isEmpty) { + return 'Please confirm your new password'; + } + + if (value != _newPasswordController.text) { + return 'Passwords do not match'; + } + + return null; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('Update Password'), + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _navigateToLogin, + tooltip: 'Cancel', + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 32), + if (!_passwordUpdated) ...[ + _buildHeader(), + const SizedBox(height: 32), + _buildInstructions(), + const SizedBox(height: 32), + _buildPasswordForm(), + const SizedBox(height: 24), + _buildSubmitButton(), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + _buildErrorMessage(), + ], + ] else ...[ + _buildSuccessMessage(), + const SizedBox(height: 32), + _buildLoginButton(), + ], + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + Icon( + Icons.lock_outline, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Set New Password', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildInstructions() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Create a strong password for your account security.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPasswordForm() { + return Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _newPasswordController, + obscureText: !_showPassword, + enabled: !_isLoading, + decoration: InputDecoration( + labelText: 'New Password', + hintText: 'Enter your new password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _showPassword ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _showPassword = !_showPassword; + }); + }, + ), + ), + validator: _validatePassword, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + obscureText: !_showConfirmPassword, + enabled: !_isLoading, + decoration: InputDecoration( + labelText: 'Confirm Password', + hintText: 'Confirm your new password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _showConfirmPassword ? Icons.visibility : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _showConfirmPassword = !_showConfirmPassword; + }); + }, + ), + ), + validator: _validateConfirmPassword, + autovalidateMode: AutovalidateMode.onUserInteraction, + ), + const SizedBox(height: 16), + _buildPasswordStrengthIndicator(), + ], + ), + ); + } + + Widget _buildPasswordStrengthIndicator() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Password Strength:', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 8), + Text( + _getPasswordStrengthText(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: _getPasswordStrengthColor(), + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: _getPasswordStrength(), + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + valueColor: AlwaysStoppedAnimation(_getPasswordStrengthColor()), + ), + const SizedBox(height: 8), + Text( + 'Password must contain:\n• At least 8 characters\n• Uppercase and lowercase letters\n• At least one number', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } + + double _getPasswordStrength() { + final password = _newPasswordController.text; + double strength = 0.0; + + if (password.length >= 8) strength += 0.25; + if (password.contains(RegExp(r'[A-Z]'))) strength += 0.25; + if (password.contains(RegExp(r'[a-z]'))) strength += 0.25; + if (password.contains(RegExp(r'[0-9]'))) strength += 0.25; + + return strength; + } + + String _getPasswordStrengthText() { + final strength = _getPasswordStrength(); + if (strength <= 0.25) return 'Weak'; + if (strength <= 0.5) return 'Fair'; + if (strength <= 0.75) return 'Good'; + return 'Strong'; + } + + Color _getPasswordStrengthColor() { + final strength = _getPasswordStrength(); + if (strength <= 0.25) return Colors.red; + if (strength <= 0.5) return Colors.orange; + if (strength <= 0.75) return Colors.yellow.shade700; + return Colors.green; + } + + Widget _buildSubmitButton() { + return AuthButton( + text: 'Update Password', + onPressed: _isPasswordValid() ? _handlePasswordUpdate : null, + isLoading: _isLoading, + icon: const Icon(Icons.lock_reset), + ); + } + + Widget _buildErrorMessage() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _errorMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ); + } + + Widget _buildSuccessMessage() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon( + Icons.check_circle_outline, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Password Updated!', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(height: 8), + Text( + 'Your password has been successfully updated. You can now sign in with your new password.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildLoginButton() { + return AuthButton( + text: 'Sign In', + onPressed: _navigateToLogin, + variant: AuthButtonVariant.primary, + icon: const Icon(Icons.login), + ); + } +} \ No newline at end of file diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart index 2b47172..aca82bf 100644 --- a/lib/providers/auth_provider.dart +++ b/lib/providers/auth_provider.dart @@ -393,6 +393,28 @@ class AuthProvider extends StateNotifier { } } + /// Updates password from reset token (password reset flow) + /// + /// [newPassword] - The new password to set + /// + /// Throws AuthException if update fails + Future updatePasswordFromReset(String newPassword) async { + if (state.isLoading) return; + + state = state.copyWith(isLoading: true, clearError: true); + + try { + await _authRepository.updatePasswordFromReset(newPassword); + state = state.copyWith(isLoading: false); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: e.toString(), + ); + rethrow; + } + } + /// Clears any authentication error void clearError() { state = state.copyWith(clearError: true);