feat(01-06): create password update page with validation

- Implemented UpdatePasswordPage with password strength indicator
- Added password validation utility with comprehensive checks
- Enhanced AuthProvider with updatePasswordFromReset method
- Extended AuthRepository interface and implementation for password reset
- Added InvalidTokenException to auth exception hierarchy
- Includes accessibility features and error handling
- Password strength indicator with real-time feedback

Files:
- lib/features/authentication/presentation/pages/update_password_page.dart
- lib/core/utils/password_validator.dart
- lib/providers/auth_provider.dart
- lib/features/authentication/domain/repositories/auth_repository.dart
- lib/features/authentication/data/repositories/auth_repository_impl.dart
- lib/core/errors/auth_exceptions.dart
This commit is contained in:
Dani B
2026-01-28 12:05:15 -05:00
parent a9f1bf75e8
commit e56dd26fef
6 changed files with 616 additions and 1 deletions

View File

@@ -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')) {

View File

@@ -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 = <String>[];
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<String> 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);
}

View File

@@ -302,6 +302,33 @@ class AuthRepositoryImpl implements AuthRepository {
}
}
@override
Future<void> 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

View File

@@ -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<AuthUser> 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<void> updatePasswordFromReset(String newPassword);
}

View File

@@ -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<UpdatePasswordPage> createState() => _UpdatePasswordPageState();
}
class _UpdatePasswordPageState extends ConsumerState<UpdatePasswordPage> {
final _formKey = GlobalKey<FormState>();
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<void> _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<Color>(_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),
);
}
}

View File

@@ -393,6 +393,28 @@ class AuthProvider extends StateNotifier<AuthState> {
}
}
/// Updates password from reset token (password reset flow)
///
/// [newPassword] - The new password to set
///
/// Throws AuthException if update fails
Future<void> 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);