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));
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
ScaffoldMessenger.of(
|
// Handle specific credential errors inline
|
||||||
context,
|
if (state.code == 'INVALID_PASSWORD' ||
|
||||||
).showSnackBar(SnackBar(content: Text(state.error)));
|
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) {
|
} 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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import (
|
|||||||
type ErrorCode string
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user