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

@@ -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),
);
}
}