Enhance authentication error handling with specific error codes and inline validation feedback
This commit is contained in:
@@ -56,7 +56,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
add(RegistrationChallengeRequested(userId: userId));
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +83,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +137,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
}
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +151,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
add(AuthenticationChallengeRequested(username: event.username));
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +181,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +235,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
}
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +292,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
}
|
||||
} catch (e) {
|
||||
final errorMessage = _extractErrorMessage(e);
|
||||
emit(AuthFailure(errorMessage));
|
||||
final code = e is ApiError ? e.code : null;
|
||||
emit(AuthFailure(errorMessage, code: code));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,11 +85,12 @@ class AuthAuthenticated extends AuthState {
|
||||
|
||||
class AuthFailure extends AuthState {
|
||||
final String error;
|
||||
final String? code;
|
||||
|
||||
const AuthFailure(this.error);
|
||||
const AuthFailure(this.error, {this.code});
|
||||
|
||||
@override
|
||||
List<Object> get props => [error];
|
||||
List<Object?> get props => [error, code];
|
||||
}
|
||||
|
||||
class AuthUnauthenticated extends AuthState {
|
||||
|
||||
@@ -32,6 +32,12 @@ class _LoginFormState extends State<LoginForm> {
|
||||
bool _usePasskey = true;
|
||||
bool _isSignup = false;
|
||||
|
||||
// UI error state for inline validation
|
||||
String? _usernameErrorText;
|
||||
String? _passwordErrorText;
|
||||
bool _usernameHasError = false;
|
||||
bool _passwordHasError = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
@@ -112,6 +118,12 @@ class _LoginFormState extends State<LoginForm> {
|
||||
_passwordController.clear();
|
||||
_displayNameController.clear();
|
||||
_usePasskey = true;
|
||||
|
||||
// Clear inline error state
|
||||
_usernameHasError = false;
|
||||
_passwordHasError = false;
|
||||
_usernameErrorText = null;
|
||||
_passwordErrorText = null;
|
||||
}
|
||||
|
||||
void _setSignupMode(bool isSignup) {
|
||||
@@ -124,9 +136,33 @@ class _LoginFormState extends State<LoginForm> {
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is AuthFailure) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.error)));
|
||||
// Handle specific credential errors inline
|
||||
if (state.code == 'INVALID_PASSWORD' ||
|
||||
state.error.toLowerCase().contains('incorrect')) {
|
||||
setState(() {
|
||||
_passwordHasError = true;
|
||||
_passwordErrorText = 'incorrect password';
|
||||
_passwordController.clear();
|
||||
// clear username error if any
|
||||
_usernameHasError = false;
|
||||
_usernameErrorText = null;
|
||||
});
|
||||
} else if (state.code == 'INVALID_CREDENTIALS' ||
|
||||
state.error.toLowerCase().contains('invalid credentials')) {
|
||||
setState(() {
|
||||
// Border both fields red but show the helper text only under the password field
|
||||
_usernameHasError = true;
|
||||
_passwordHasError = true;
|
||||
_usernameErrorText = null;
|
||||
_passwordErrorText = 'invalid credentials';
|
||||
_usernameController.clear();
|
||||
_passwordController.clear();
|
||||
});
|
||||
} else {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(state.error)));
|
||||
}
|
||||
} else if (state is AuthenticationChallengeReceived) {
|
||||
_handleAuthentication(context, state);
|
||||
} else if (state is RegistrationChallengeReceived) {
|
||||
@@ -171,7 +207,9 @@ class _LoginFormState extends State<LoginForm> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
color: _usernameHasError
|
||||
? Colors.red
|
||||
: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
@@ -179,6 +217,14 @@ class _LoginFormState extends State<LoginForm> {
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
onChanged: (_) {
|
||||
if (_usernameHasError || _usernameErrorText != null) {
|
||||
setState(() {
|
||||
_usernameHasError = false;
|
||||
_usernameErrorText = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
@@ -193,6 +239,17 @@ class _LoginFormState extends State<LoginForm> {
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (_usernameErrorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 8),
|
||||
child: Text(
|
||||
_usernameErrorText!,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
@@ -204,7 +261,9 @@ class _LoginFormState extends State<LoginForm> {
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
color: _passwordHasError
|
||||
? Colors.red
|
||||
: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
@@ -213,6 +272,15 @@ class _LoginFormState extends State<LoginForm> {
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
onChanged: (_) {
|
||||
if (_passwordHasError ||
|
||||
_passwordErrorText != null) {
|
||||
setState(() {
|
||||
_passwordHasError = false;
|
||||
_passwordErrorText = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
@@ -227,6 +295,17 @@ class _LoginFormState extends State<LoginForm> {
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (_passwordErrorText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 8),
|
||||
child: Text(
|
||||
_passwordErrorText!,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
@@ -362,7 +441,12 @@ class _LoginFormState extends State<LoginForm> {
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _usePasskey = !_usePasskey);
|
||||
setState(() {
|
||||
_usePasskey = !_usePasskey;
|
||||
// Clear password errors when switching modes
|
||||
_passwordHasError = false;
|
||||
_passwordErrorText = null;
|
||||
});
|
||||
widget.onPasswordModeChanged?.call(
|
||||
!_usePasskey,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||
// More specific authentication error codes
|
||||
CodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
|
||||
CodeInvalidPassword ErrorCode = "INVALID_PASSWORD"
|
||||
|
||||
CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
|
||||
CodeNotFound ErrorCode = "NOT_FOUND"
|
||||
CodeConflict ErrorCode = "CONFLICT"
|
||||
|
||||
@@ -1921,6 +1921,18 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
||||
Metadata: map[string]interface{}{"error": err.Error()},
|
||||
})
|
||||
errors.LogError(r, err, "Password login failed")
|
||||
// Map internal errors to more specific API error responses so the client
|
||||
// can indicate which field was incorrect.
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "invalid password") {
|
||||
errors.WriteError(w, errors.CodeInvalidPassword, "Incorrect password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if strings.Contains(errMsg, "user not found") {
|
||||
errors.WriteError(w, errors.CodeInvalidCredentials, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user