Enhance authentication error handling with specific error codes and inline validation feedback

This commit is contained in:
Leon Bösche
2026-02-01 00:24:19 +01:00
parent 17ac65a493
commit 96a044450f
5 changed files with 124 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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
}