🎯 Learning Path:
Welcome to the REL-ID Password Expiry codelab! This tutorial builds upon your existing MFA implementation to add secure expired password update capabilities using REL-ID SDK's updatePassword API.
In this codelab, you'll enhance your existing MFA application with:
challengeMode = 4 (RDNA_OP_UPDATE_ON_EXPIRY)RELID_PASSWORD_POLICY requirementsBy completing this codelab, you'll master:
updatePassword(current, new, challengeMode) with proper handlingRELID_PASSWORD_POLICY from challenge dataBefore starting this codelab, ensure you have:
The code to get started can be found in a GitHub repository.
You can clone the repository using the following command:
git clone https://github.com/uniken-public/codelab-flutter.git
Navigate to the relid-MFA-password-expiry folder in the repository you cloned earlier
This codelab extends your MFA application with three core password expiry components:
Before implementing password expiry functionality, let's understand the key SDK events and APIs that power the expired password update workflow.
The password expiry process follows this event-driven pattern:
Login with Expired Password with challengeMode=0 → Server Detects Expiry (statusCode 118) →
SDK Triggers getPassword Event with challengeMode=4 → UpdateExpiryPasswordScreen Displays →
User Updates Password → updatePassword(current, new, challengeMode) API → onUserLoggedIn Event → Dashboard
When a user's password expires, the login flow changes:
Step | Event | Description |
1. User Login | VerifyPasswordScreen with | User enters credentials for standard login |
2. Password Expired | Server returns | Server detects password has expired |
3. SDK Re-triggers |
| SDK automatically requests password update |
4. User Shows Screen | UpdateExpiryPasswordScreen displays | Show UpdateExpiryPasswordScreen with current, new, and confirm password fields |
5. User Update Password | updatePassword API | User must provide current and new password |
Challenge Mode 4 is specifically for expired password updates:
Challenge Mode | Purpose | User Action Required | Screen |
| Verify existing password | Enter password to login | VerifyPasswordScreen |
| Set new password | Create password during activation | SetPasswordScreen |
| Update expired password | Provide current + new password | UpdateExpiryPasswordScreen |
The REL-ID SDK triggers these main events during password expiry flow:
Event Type | Description | User Action Required |
Password expiry detected, update required | User provides current and new passwords | |
Automatic login after successful password update | System navigates to dashboard automatically |
Password expiry flow uses the same default policy key as password creation:
Flow | Policy Key | Description |
Password Creation (challengeMode=1) |
| Policy for new password creation |
Password Expiry (challengeMode=4) |
| Policy for expired password update |
The server maintains password history and detects reuse:
Status Code | Meaning | Action |
| Password has expired | Initial trigger for password update |
| Password reuse detected | Clear fields and prompt for different password |
Add these Dart definitions to understand the updatePassword API structure:
// lib/uniken/services/rdna_service.dart (password expiry addition)
/// Updates password when expired (Password Expiry Flow)
///
/// This method is specifically used for updating expired passwords during the MFA flow.
/// When a password is expired during login (challengeMode=0), the SDK automatically
/// re-triggers getPassword() with challengeMode=4 (RDNA_OP_UPDATE_ON_EXPIRY).
/// The app should then call this method with both current and new passwords.
///
/// ## Parameters
/// - [currentPassword]: The user's current password
/// - [newPassword]: The new password to set
/// - [challengeMode]: Challenge mode (should be RDNAChallengeOpMode.RDNA_OP_UPDATE_ON_EXPIRY for password expiry)
///
/// ## Returns
/// Future<RDNASyncResponse> that resolves with sync response structure
///
/// ## Events Triggered
/// - onUserLoggedIn: Successful password update triggers login event
/// - getPassword: May be re-triggered if validation fails
Future<RDNASyncResponse> updatePassword(
String currentPassword,
String newPassword,
RDNAChallengeOpMode challengeMode
) async {
print('RdnaService - Updating expired password (challengeMode: $challengeMode)');
final response = await _rdnaClient.updatePassword(
currentPassword,
newPassword,
challengeMode
);
print('RdnaService - UpdatePassword sync response received');
print(' Long Error Code: ${response.error?.longErrorCode}');
print(' Short Error Code: ${response.error?.shortErrorCode}');
return response;
}
Let's implement the updatePassword API in your service layer following established REL-ID SDK patterns.
Add the updatePassword method to your existing service implementation:
// lib/uniken/services/rdna_service.dart (addition to existing class)
/// Updates password when expired (Password Expiry Flow)
///
/// This method is specifically used for updating expired passwords during the MFA flow.
/// When a password is expired during login (challengeMode=0), the SDK automatically
/// re-triggers getPassword() with challengeMode=4 (RDNA_OP_UPDATE_ON_EXPIRY).
/// The app should then call this method with both current and new passwords.
///
/// ## Workflow
/// 1. User logs in with expired password (challengeMode=0)
/// 2. Server detects expiry (statusCode=118)
/// 3. SDK triggers getPassword with challengeMode=4
/// 4. App displays UpdateExpiryPasswordScreen
/// 5. User provides current and new passwords
/// 6. App calls updatePassword(current, new, challengeMode)
/// 7. SDK validates and updates password
/// 8. SDK logs user in automatically (onUserLoggedIn event)
///
/// ## Response Validation Logic
/// 1. Check error.longErrorCode: 0 = success, > 0 = error
/// 2. On success, triggers onUserLoggedIn event immediately
/// 3. On failure, may trigger getPassword again with error status
/// 4. StatusCode 164 = Password reuse error (used in last N passwords)
/// 5. Async events will be handled by event listeners
///
/// ## Parameters
/// - [currentPassword]: The user's current password
/// - [newPassword]: The new password to set
/// - [challengeMode]: Challenge mode (should be RDNAChallengeOpMode.RDNA_OP_UPDATE_ON_EXPIRY for password expiry)
///
/// ## Returns
/// Future<RDNASyncResponse> that resolves with sync response structure
///
/// ## See Also
/// - [Password Expiry Documentation](https://developer.uniken.com/docs/password-expiry)
Future<RDNASyncResponse> updatePassword(
String currentPassword,
String newPassword,
RDNAChallengeOpMode challengeMode
) async {
print('RdnaService - Updating expired password (challengeMode: $challengeMode)');
final response = await _rdnaClient.updatePassword(
currentPassword,
newPassword,
challengeMode
);
print('RdnaService - UpdatePassword sync response received');
print(' Long Error Code: ${response.error?.longErrorCode}');
print(' Short Error Code: ${response.error?.shortErrorCode}');
return response;
}
Notice how this implementation follows the exact pattern established by other service methods:
Pattern Element | Implementation Detail |
Future Wrapper | Returns Future for async/await usage |
Error Checking | SDK response includes error?.longErrorCode for validation |
Logging Strategy | Comprehensive logging for debugging (without exposing passwords) |
Error Handling | Caller checks response.error?.longErrorCode for success |
Challenge Mode | Uses RDNAChallengeOpMode enum (RDNA_OP_UPDATE_ON_EXPIRY) |
Now let's enhance your SDKEventProvider to detect and route challengeMode 4 to the UpdateExpiryPasswordScreen.
Update your existing _handleGetPassword callback in SDKEventProvider:
// lib/uniken/providers/sdk_event_provider.dart (enhancement to existing handler)
/// Event handler for get password requests
///
/// Navigates to appropriate password screen based on challengeMode:
/// - challengeMode 0: VerifyPasswordScreen (login/verify)
/// - challengeMode 1: SetPasswordScreen (create new password)
/// - challengeMode 4: UpdateExpiryPasswordScreen (update expired password)
void _handleGetPassword(RDNAGetPassword data) {
print('SDKEventProvider - Get password event received');
print(' Status Code: ${data.challengeResponse?.status?.statusCode}');
print(' UserID: ${data.userId}, ChallengeMode: ${data.challengeMode}, AttemptsLeft: ${data.attemptsLeft}');
// Navigate based on challenge mode
if (data.challengeMode == 0) {
// Mode 0: Verify existing password (login)
print('SDKEventProvider - Routing to VerifyPasswordScreen (challengeMode 0)');
appRouter.goNamed('verifyPasswordScreen', extra: data);
} else if (data.challengeMode == 4) {
// Mode 4: Update expired password (RDNA_OP_UPDATE_ON_EXPIRY)
print('SDKEventProvider - Routing to UpdateExpiryPasswordScreen (challengeMode 4)');
appRouter.goNamed('updateExpiryPasswordScreen', extra: data);
} else {
// Mode 1 or other: Set new password
print('SDKEventProvider - Routing to SetPasswordScreen (challengeMode ${data.challengeMode})');
appRouter.goNamed('setPasswordScreen', extra: data);
}
}
The enhanced routing logic handles three password scenarios:
Challenge Mode | Screen | Purpose |
| VerifyPasswordScreen | Verify existing password for login |
| SetPasswordScreen | Set new password during activation |
| UpdateExpiryPasswordScreen | Update expired password |
The server provides status messages that explain why password update is required:
Status Code | Typical Status Message |
| "Password has expired. Please contact the admin." |
| "Please enter a new password as your entered password has been used by you previously. You are not allowed to use last N passwords." |
Now let's create the UpdateExpiryPasswordScreen component with three password fields, comprehensive validation, and keyboard management.
Create a new file for the password expiry screen:
// lib/tutorial/screens/mfa/update_expiry_password_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rdna_client/rdna_struct.dart';
import '../../../uniken/services/rdna_service.dart';
import '../../../uniken/utils/password_policy_utils.dart';
import '../../../uniken/utils/rdna_event_utils.dart';
import '../components/custom_button.dart';
import '../components/custom_input.dart';
import '../components/status_banner.dart';
import '../components/close_button.dart';
/// Update Expiry Password Screen Component
///
/// Handles the password expiry flow where users must update their expired
/// password by providing current password, new password, and confirmation.
class UpdateExpiryPasswordScreen extends ConsumerStatefulWidget {
final RDNAGetPassword? eventData;
const UpdateExpiryPasswordScreen({
super.key,
this.eventData,
});
@override
ConsumerState<UpdateExpiryPasswordScreen> createState() =>
_UpdateExpiryPasswordScreenState();
}
class _UpdateExpiryPasswordScreenState
extends ConsumerState<UpdateExpiryPasswordScreen> {
final _currentPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
final _currentPasswordFocusNode = FocusNode();
final _newPasswordFocusNode = FocusNode();
final _confirmPasswordFocusNode = FocusNode();
String? _error;
bool _isSubmitting = false;
bool _obscureCurrentPassword = true;
bool _obscureNewPassword = true;
bool _obscureConfirmPassword = true;
String? _passwordPolicyMessage;
String? _userName;
int _challengeMode = 4; // RDNA_OP_UPDATE_ON_EXPIRY
@override
void initState() {
super.initState();
_processEventData();
// Auto-focus on current password field
WidgetsBinding.instance.addPostFrameCallback((_) {
_currentPasswordFocusNode.requestFocus();
});
}
@override
void didUpdateWidget(UpdateExpiryPasswordScreen oldWidget) {
super.didUpdateWidget(oldWidget);
// Re-process when eventData changes
if (widget.eventData != oldWidget.eventData) {
print('UpdateExpiryPasswordScreen - Event data updated, re-processing');
_processEventData();
}
}
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
_currentPasswordFocusNode.dispose();
_newPasswordFocusNode.dispose();
_confirmPasswordFocusNode.dispose();
super.dispose();
}
// ... (Continue with remaining handler functions in next section)
}
Feature | Implementation Detail |
Three Password Fields | Current, new, and confirm password with TextEditingController |
Focus Management | FocusNode for each field to control keyboard focus |
ConsumerStatefulWidget | Riverpod integration for state management |
Error Handling | Automatic field clearing on API and status errors |
Loading States | Proper _isSubmitting state management |
Let's implement comprehensive password validation for the three-field form.
Implement handlers to process event data and clear fields on errors:
// lib/tutorial/screens/mfa/update_expiry_password_screen.dart (additions)
/// Handle response data from event
void _processEventData() {
if (widget.eventData == null) return;
final responseData = widget.eventData!;
print('UpdateExpiryPasswordScreen - Processing response data from event');
setState(() {
_userName = responseData.userId ?? '';
_challengeMode = responseData.challengeMode ?? 4;
});
// Extract and process password policy
final policyJsonString = RDNAEventUtils.getChallengeValue(
responseData.challengeResponse?.challengeInfo,
'RELID_PASSWORD_POLICY',
);
if (policyJsonString != null) {
final policyMessage = parseAndGeneratePolicyMessage(policyJsonString);
setState(() {
_passwordPolicyMessage = policyMessage;
});
print('UpdateExpiryPasswordScreen - Password policy extracted: $policyMessage');
}
// Check for API errors first
if (RDNAEventUtils.hasApiError(responseData.error)) {
final errorMessage = RDNAEventUtils.getErrorMessage(responseData.error, null);
print('UpdateExpiryPasswordScreen - API error: $errorMessage');
setState(() {
_error = errorMessage;
_isSubmitting = false;
});
_clearPasswordFields();
return;
}
// Check for status errors (including password reuse errors like statusCode 164)
if (RDNAEventUtils.hasStatusError(responseData.challengeResponse?.status)) {
final errorMessage = RDNAEventUtils.getErrorMessage(
responseData.error, responseData.challengeResponse?.status);
print('UpdateExpiryPasswordScreen - Status error: $errorMessage');
setState(() {
_error = errorMessage;
_isSubmitting = false;
});
_clearPasswordFields();
return;
}
}
/// Clear all password fields
void _clearPasswordFields() {
_currentPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
_currentPasswordFocusNode.requestFocus();
}
/// Handle input changes - update and clear error when user types
void _onCurrentPasswordChanged(String value) {
setState(() {
_currentPasswordController.text = value;
if (_error != null) _error = null;
});
}
void _onNewPasswordChanged(String value) {
setState(() {
_newPasswordController.text = value;
if (_error != null) _error = null;
});
}
void _onConfirmPasswordChanged(String value) {
setState(() {
_confirmPasswordController.text = value;
if (_error != null) _error = null;
});
}
Add the main validation and update logic:
// lib/tutorial/screens/mfa/update_expiry_password_screen.dart (additions)
/// Handle password update submission
Future<void> _handleUpdatePassword() async {
if (_isSubmitting) return;
final trimmedCurrentPassword = _currentPasswordController.text.trim();
final trimmedNewPassword = _newPasswordController.text.trim();
final trimmedConfirmPassword = _confirmPasswordController.text.trim();
// Basic validation
if (trimmedCurrentPassword.isEmpty) {
setState(() {
_error = 'Please enter your current password';
});
_currentPasswordFocusNode.requestFocus();
return;
}
if (trimmedNewPassword.isEmpty) {
setState(() {
_error = 'Please enter a new password';
});
_newPasswordFocusNode.requestFocus();
return;
}
if (trimmedConfirmPassword.isEmpty) {
setState(() {
_error = 'Please confirm your new password';
});
_confirmPasswordFocusNode.requestFocus();
return;
}
// Check password match
if (trimmedNewPassword != trimmedConfirmPassword) {
setState(() {
_error = 'New password and confirm password do not match';
});
// Show alert dialog
if (mounted) {
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Password Mismatch'),
content: const Text('New password and confirm password do not match'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
_newPasswordController.clear();
_confirmPasswordController.clear();
_newPasswordFocusNode.requestFocus();
},
child: const Text('OK'),
),
],
),
);
}
return;
}
// Check if new password is same as current password
if (trimmedCurrentPassword == trimmedNewPassword) {
setState(() {
_error = 'New password must be different from current password';
});
// Show alert dialog
if (mounted) {
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Invalid New Password'),
content: const Text('Your new password must be different from your current password'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
_newPasswordController.clear();
_confirmPasswordController.clear();
_newPasswordFocusNode.requestFocus();
},
child: const Text('OK'),
),
],
),
);
}
return;
}
setState(() {
_isSubmitting = true;
_error = null;
});
try {
print('UpdateExpiryPasswordScreen - Updating password with challengeMode: $_challengeMode');
final rdnaService = RdnaService.getInstance();
final response = await rdnaService.updatePassword(
trimmedCurrentPassword,
trimmedNewPassword,
RDNAChallengeOpMode.RDNA_OP_UPDATE_ON_EXPIRY, // challengeMode 4
);
print('UpdateExpiryPasswordScreen - UpdatePassword sync response successful');
print(' longErrorCode: ${response.error?.longErrorCode}');
print(' shortErrorCode: ${response.error?.shortErrorCode}');
// Check sync response for errors
if (response.error?.longErrorCode != 0) {
final errorMessage = response.error?.errorString ?? 'Unknown error';
print('UpdateExpiryPasswordScreen - Sync error: $errorMessage');
setState(() {
_error = errorMessage;
_isSubmitting = false;
});
_clearPasswordFields();
return;
}
// Success - wait for onUserLoggedIn event
// Event handlers in SDKEventProvider will handle the navigation
print('UpdateExpiryPasswordScreen - Sync success, waiting for onUserLoggedIn event');
} catch (error) {
print('UpdateExpiryPasswordScreen - Runtime error: $error');
setState(() {
_error = 'An unexpected error occurred. Please try again.';
_isSubmitting = false;
});
_clearPasswordFields();
}
}
/// Handle close button - reset authentication state
void _handleClose() {
print('UpdateExpiryPasswordScreen - Calling resetAuthState');
final rdnaService = RdnaService.getInstance();
rdnaService.resetAuthState().then((_) {
print('UpdateExpiryPasswordScreen - ResetAuthState successful');
}).catchError((error) {
print('UpdateExpiryPasswordScreen - ResetAuthState error: $error');
});
}
/// Check if form is valid
bool get _isFormValid {
return _currentPasswordController.text.trim().isNotEmpty &&
_newPasswordController.text.trim().isNotEmpty &&
_confirmPasswordController.text.trim().isNotEmpty &&
_error == null;
}
Validation Rule | Error Message | Action |
Current password empty | "Please enter your current password" | Focus current password field |
New password empty | "Please enter a new password" | Focus new password field |
Confirm password empty | "Please confirm your new password" | Focus confirm password field |
Passwords don't match | "New password and confirm password do not match" | Clear new and confirm fields |
New = Current password | "New password must be different from current password" | Clear new and confirm fields |
Now let's build the complete UI with password policy requirements display and responsive layout.
Complete the component with the full UI:
// lib/tutorial/screens/mfa/update_expiry_password_screen.dart (UI rendering)
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFf8f9fa),
body: SafeArea(
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.all(20),
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 60), // Space for close button
const Text(
'Update Expired Password',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF2c3e50),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text(
'Your password has expired. Please update it to continue.',
style: TextStyle(
fontSize: 16,
color: Color(0xFF7f8c8d),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// User Information
if (_userName != null && _userName!.isNotEmpty)
Column(
children: [
const Text(
'Welcome',
style: TextStyle(
fontSize: 18,
color: Color(0xFF2c3e50),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
_userName!,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF3498db),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
],
),
// Password Policy Display
if (_passwordPolicyMessage != null)
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: const Color(0xFFf0f8ff),
border: const Border(
left: BorderSide(
color: Color(0xFF3498db),
width: 4,
),
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Password Requirements',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF2c3e50),
),
),
const SizedBox(height: 8),
Text(
_passwordPolicyMessage!,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF2c3e50),
height: 1.43,
),
),
],
),
),
// Error Display
if (_error != null)
StatusBanner(
type: StatusBannerType.error,
message: _error!,
),
// Current Password Input
CustomInput(
label: 'Current Password',
value: _currentPasswordController.text,
onChanged: _onCurrentPasswordChanged,
placeholder: 'Enter current password',
obscureText: _obscureCurrentPassword,
enabled: !_isSubmitting,
focusNode: _currentPasswordFocusNode,
textInputAction: TextInputAction.next,
onSubmitted: () => _newPasswordFocusNode.requestFocus(),
suffixIcon: IconButton(
icon: Icon(
_obscureCurrentPassword
? Icons.visibility
: Icons.visibility_off,
color: const Color(0xFF7f8c8d),
),
onPressed: () {
setState(() {
_obscureCurrentPassword = !_obscureCurrentPassword;
});
},
),
),
const SizedBox(height: 20),
// New Password Input
CustomInput(
label: 'New Password',
value: _newPasswordController.text,
onChanged: _onNewPasswordChanged,
placeholder: 'Enter new password',
obscureText: _obscureNewPassword,
enabled: !_isSubmitting,
focusNode: _newPasswordFocusNode,
textInputAction: TextInputAction.next,
onSubmitted: () => _confirmPasswordFocusNode.requestFocus(),
suffixIcon: IconButton(
icon: Icon(
_obscureNewPassword
? Icons.visibility
: Icons.visibility_off,
color: const Color(0xFF7f8c8d),
),
onPressed: () {
setState(() {
_obscureNewPassword = !_obscureNewPassword;
});
},
),
),
const SizedBox(height: 20),
// Confirm New Password Input
CustomInput(
label: 'Confirm New Password',
value: _confirmPasswordController.text,
onChanged: _onConfirmPasswordChanged,
placeholder: 'Confirm new password',
obscureText: _obscureConfirmPassword,
enabled: !_isSubmitting,
focusNode: _confirmPasswordFocusNode,
textInputAction: TextInputAction.done,
onSubmitted: _handleUpdatePassword,
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility
: Icons.visibility_off,
color: const Color(0xFF7f8c8d),
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
const SizedBox(height: 20),
// Submit Button
CustomButton(
title: _isSubmitting
? 'Updating Password...'
: 'Update Password',
onPress: _handleUpdatePassword,
loading: _isSubmitting,
disabled: !_isFormValid,
),
// Help Text
Container(
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFe8f4f8),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'Update your password. Your new password must be different from your current password.',
style: TextStyle(
fontSize: 14,
color: Color(0xFF2c3e50),
height: 1.43,
),
textAlign: TextAlign.center,
),
),
],
),
),
// Close Button
CustomCloseButton(
onPressed: _handleClose,
disabled: _isSubmitting,
),
],
),
),
);
}
Component | Purpose | Key Props |
Scaffold | Root widget with background color |
|
SafeArea | Ensures content within safe display area | Handles notches and system UI |
Stack | Layered layout for overlay elements | Close button over scrollable content |
SingleChildScrollView | Scrollable container with keyboard handling |
|
Policy Container | Display password requirements | Shows parsed RELID_PASSWORD_POLICY |
Three CustomInput Fields | Current, new, confirm passwords | Each with FocusNode for keyboard navigation |
StatusBanner | Display errors (including statusCode 164) | Conditional rendering |
CustomButton | Trigger password update | Disabled until form valid |
Each CustomInput component has keyboard navigation configured:
Input Field | Focus Node | Next Field Focus |
Current Password |
| Focuses |
New Password |
| Focuses |
Confirm Password |
| Calls |
This creates a seamless user experience where pressing the keyboard's "Next" button automatically advances to the next field, and "Done" on the final field submits the form.
The following images showcase screens from the sample application:
|
|
Let's register the UpdateExpiryPasswordScreen in your navigation configuration.
Add the screen route to your GoRouter configuration:
// lib/tutorial/navigation/app_router.dart (route additions)
import 'package:go_router/go_router.dart';
import 'package:rdna_client/rdna_struct.dart';
import '../screens/mfa/update_expiry_password_screen.dart';
final appRouter = GoRouter(
initialLocation: '/',
routes: [
// ... other routes
GoRoute(
path: '/update-expiry-password',
name: 'updateExpiryPasswordScreen',
builder: (context, state) {
final data = state.extra as RDNAGetPassword?;
return UpdateExpiryPasswordScreen(eventData: data);
},
),
],
);
Add the screen to your MFA screens export:
// lib/tutorial/screens/mfa/index.dart
export 'check_user_screen.dart';
export 'activation_code_screen.dart';
export 'user_lda_consent_screen.dart';
export 'set_password_screen.dart';
export 'verify_password_screen.dart';
export 'update_expiry_password_screen.dart';
export 'verify_auth_screen.dart';
export 'dashboard_screen.dart';
Verify your navigation flow is complete:
Step | Navigation Event | Screen |
1. User Login |
| VerifyPasswordScreen |
2. Password Expired |
| UpdateExpiryPasswordScreen |
3. Password Updated |
| DashboardScreen |
Now let's test the complete password expiry implementation with various scenarios.
Follow these steps to test standard password expiry:
Test password reuse error handling:
Test all validation rules:
Test Case | Expected Error | Expected Behavior |
Empty current password | "Please enter your current password" | Focus current password field |
Empty new password | "Please enter a new password" | Focus new password field |
Empty confirm password | "Please confirm your new password" | Focus confirm password field |
Passwords don't match | "New password and confirm password do not match" | Alert + clear new/confirm fields |
New = Current password | "New password must be different from current password" | Alert + clear new/confirm fields |
Test password policy enforcement:
If you encounter issues, check these areas:
Issue | Possible Cause | Solution |
Policy not displaying | Using wrong policy key | Update extraction key to RELID_PASSWORD_POLICY |
Fields not clearing | Missing field clear logic in error handling | Add clear() calls for all controllers |
Navigation not working | challengeMode 4 not routed in SDKEventProvider | Add if (data.challengeMode == 4) routing |
API not called | Form validation failing | Check _isFormValid getter logic |
Focus not working | FocusNode not properly assigned | Verify each CustomInput has correct focusNode |
Before deploying password expiry functionality to production, review these important considerations.
Practice | Implementation | Importance |
Never log passwords | Remove all print statements that might expose passwords | Critical |
Password history | Respect server-configured history limits | High |
Policy enforcement | Always display and enforce RELID_PASSWORD_POLICY | High |
Error handling | Clear fields on all errors to prevent data exposure | High |
Enhance user experience with these patterns:
// 1. Clear, specific error messages
if (trimmedNewPassword == trimmedCurrentPassword) {
setState(() {
_error = 'New password must be different from current password';
});
// Show alert dialog with actionable guidance
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Invalid New Password'),
content: const Text('Your new password must be different from your current password'),
),
);
}
// 2. Automatic field clearing on errors
if (RDNAEventUtils.hasStatusError(responseData.challengeResponse?.status)) {
setState(() {
_error = errorMessage;
_isSubmitting = false;
});
_clearPasswordFields();
}
// 3. Keyboard navigation with FocusNode
CustomInput(
textInputAction: TextInputAction.next,
onSubmitted: () => _newPasswordFocusNode.requestFocus(),
)
// 4. Scroll behavior for better input visibility
SingleScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
child: Column(...),
)
Consideration | Implementation |
Controllers | Use TextEditingController for efficient text input management |
Focus Nodes | Use FocusNode for efficient focus management |
Immutability | Use const constructors where possible for performance |
Error recovery | Implement retry logic with proper state cleanup |
Before production deployment, verify:
Congratulations! You've successfully implemented REL-ID Password Expiry functionality in your Flutter application.
You now have:
✅ Password Expiry Detection: Automatic detection and routing of challengeMode 4
✅ UpdatePassword API: Full integration with proper error handling
✅ Three-Field Validation: Current, new, and confirm password validation
✅ Password Policy Display: Extraction and display of RELID_PASSWORD_POLICY
✅ Password Reuse Handling: StatusCode 164 detection with automatic field clearing
✅ Production-Ready: Secure, user-friendly password expiry flow
Thank you for completing the REL-ID Password Expiry Flow Codelab!
You're now equipped to build secure, production-ready password expiry workflows that provide excellent user experience while maintaining strong security standards.