From 96a044450fb83350b1f958a0ce573647364594d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Sun, 1 Feb 2026 00:24:19 +0100 Subject: [PATCH] Enhance authentication error handling with specific error codes and inline validation feedback --- b0esche_cloud/lib/blocs/auth/auth_bloc.dart | 21 +++-- b0esche_cloud/lib/blocs/auth/auth_state.dart | 5 +- b0esche_cloud/lib/pages/login_form.dart | 96 ++++++++++++++++++-- go_cloud/internal/errors/errors.go | 6 +- go_cloud/internal/http/routes.go | 12 +++ 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart index c21f9ea..21b4407 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_bloc.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_bloc.dart @@ -56,7 +56,8 @@ class AuthBloc extends Bloc { 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 { ); } 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 { } } 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 { 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 { ); } 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 { } } 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 { } } catch (e) { final errorMessage = _extractErrorMessage(e); - emit(AuthFailure(errorMessage)); + final code = e is ApiError ? e.code : null; + emit(AuthFailure(errorMessage, code: code)); } } diff --git a/b0esche_cloud/lib/blocs/auth/auth_state.dart b/b0esche_cloud/lib/blocs/auth/auth_state.dart index bb24d21..7f0db3f 100644 --- a/b0esche_cloud/lib/blocs/auth/auth_state.dart +++ b/b0esche_cloud/lib/blocs/auth/auth_state.dart @@ -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 get props => [error]; + List get props => [error, code]; } class AuthUnauthenticated extends AuthState { diff --git a/b0esche_cloud/lib/pages/login_form.dart b/b0esche_cloud/lib/pages/login_form.dart index d0a0bbe..68b2b74 100644 --- a/b0esche_cloud/lib/pages/login_form.dart +++ b/b0esche_cloud/lib/pages/login_form.dart @@ -32,6 +32,12 @@ class _LoginFormState extends State { 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 { _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 { return BlocListener( 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 { ), 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 { 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 { 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 { ), 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 { 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 { 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 { children: [ GestureDetector( onTap: () { - setState(() => _usePasskey = !_usePasskey); + setState(() { + _usePasskey = !_usePasskey; + // Clear password errors when switching modes + _passwordHasError = false; + _passwordErrorText = null; + }); widget.onPasswordModeChanged?.call( !_usePasskey, ); diff --git a/go_cloud/internal/errors/errors.go b/go_cloud/internal/errors/errors.go index fa42527..f2e8bd2 100644 --- a/go_cloud/internal/errors/errors.go +++ b/go_cloud/internal/errors/errors.go @@ -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" diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index b01fe20..da4e3ee 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -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 }