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)); add(RegistrationChallengeRequested(userId: userId));
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(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) { } catch (e) {
final errorMessage = _extractErrorMessage(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) { } catch (e) {
final errorMessage = _extractErrorMessage(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)); add(AuthenticationChallengeRequested(username: event.username));
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(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) { } catch (e) {
final errorMessage = _extractErrorMessage(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) { } catch (e) {
final errorMessage = _extractErrorMessage(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) { } catch (e) {
final errorMessage = _extractErrorMessage(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 { class AuthFailure extends AuthState {
final String error; final String error;
final String? code;
const AuthFailure(this.error); const AuthFailure(this.error, {this.code});
@override @override
List<Object> get props => [error]; List<Object?> get props => [error, code];
} }
class AuthUnauthenticated extends AuthState { class AuthUnauthenticated extends AuthState {

View File

@@ -32,6 +32,12 @@ class _LoginFormState extends State<LoginForm> {
bool _usePasskey = true; bool _usePasskey = true;
bool _isSignup = false; bool _isSignup = false;
// UI error state for inline validation
String? _usernameErrorText;
String? _passwordErrorText;
bool _usernameHasError = false;
bool _passwordHasError = false;
@override @override
void dispose() { void dispose() {
_usernameController.dispose(); _usernameController.dispose();
@@ -112,6 +118,12 @@ class _LoginFormState extends State<LoginForm> {
_passwordController.clear(); _passwordController.clear();
_displayNameController.clear(); _displayNameController.clear();
_usePasskey = true; _usePasskey = true;
// Clear inline error state
_usernameHasError = false;
_passwordHasError = false;
_usernameErrorText = null;
_passwordErrorText = null;
} }
void _setSignupMode(bool isSignup) { void _setSignupMode(bool isSignup) {
@@ -124,9 +136,33 @@ class _LoginFormState extends State<LoginForm> {
return BlocListener<AuthBloc, AuthState>( return BlocListener<AuthBloc, AuthState>(
listener: (context, state) { listener: (context, state) {
if (state is AuthFailure) { if (state is AuthFailure) {
// 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( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text(state.error))); ).showSnackBar(SnackBar(content: Text(state.error)));
}
} else if (state is AuthenticationChallengeReceived) { } else if (state is AuthenticationChallengeReceived) {
_handleAuthentication(context, state); _handleAuthentication(context, state);
} else if (state is RegistrationChallengeReceived) { } else if (state is RegistrationChallengeReceived) {
@@ -171,7 +207,9 @@ class _LoginFormState extends State<LoginForm> {
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _usernameHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextField( child: TextField(
@@ -179,6 +217,14 @@ class _LoginFormState extends State<LoginForm> {
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
onChanged: (_) {
if (_usernameHasError || _usernameErrorText != null) {
setState(() {
_usernameHasError = false;
_usernameErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'username', hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -193,6 +239,17 @@ class _LoginFormState extends State<LoginForm> {
style: const TextStyle(color: AppTheme.primaryText), 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), const SizedBox(height: 16),
if (!_isSignup && _usePasskey) if (!_isSignup && _usePasskey)
const SizedBox.shrink() const SizedBox.shrink()
@@ -204,7 +261,9 @@ class _LoginFormState extends State<LoginForm> {
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _passwordHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextField( child: TextField(
@@ -213,6 +272,15 @@ class _LoginFormState extends State<LoginForm> {
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
obscureText: true, obscureText: true,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
onChanged: (_) {
if (_passwordHasError ||
_passwordErrorText != null) {
setState(() {
_passwordHasError = false;
_passwordErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'password', hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -227,6 +295,17 @@ class _LoginFormState extends State<LoginForm> {
style: const TextStyle(color: AppTheme.primaryText), 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) if (!_isSignup && _usePasskey)
const SizedBox.shrink() const SizedBox.shrink()
else else
@@ -362,7 +441,12 @@ class _LoginFormState extends State<LoginForm> {
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () {
setState(() => _usePasskey = !_usePasskey); setState(() {
_usePasskey = !_usePasskey;
// Clear password errors when switching modes
_passwordHasError = false;
_passwordErrorText = null;
});
widget.onPasswordModeChanged?.call( widget.onPasswordModeChanged?.call(
!_usePasskey, !_usePasskey,
); );

View File

@@ -15,6 +15,10 @@ type ErrorCode string
const ( const (
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED" CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
// More specific authentication error codes
CodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
CodeInvalidPassword ErrorCode = "INVALID_PASSWORD"
CodePermissionDenied ErrorCode = "PERMISSION_DENIED" CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
CodeNotFound ErrorCode = "NOT_FOUND" CodeNotFound ErrorCode = "NOT_FOUND"
CodeConflict ErrorCode = "CONFLICT" 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()}, Metadata: map[string]interface{}{"error": err.Error()},
}) })
errors.LogError(r, err, "Password login failed") 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) errors.WriteError(w, errors.CodeUnauthenticated, "Invalid credentials", http.StatusUnauthorized)
return return
} }