Shuffle/app/lib/features/auth/data/auth_failure_mapper.dart
cschlaefke 239d0e1ac2
All checks were successful
CI / flutter (pull_request) Successful in 1m16s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 32s
fix(auth): address review findings for registration
- expose legal links to screen readers on web
- allow only http(s) legal URLs, prevent double submit
- detect transport errors without runtimeType, keep extra in memory
- ship third-party license text, document decisions

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:47:42 +01:00

118 lines
4.3 KiB
Dart

import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:shuffle/core/platform/io_error.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Aufruf, bei dem ein Fehler auftrat. Manche Zuordnungen hängen davon ab
/// (z. B. HTTP 500 nur beim `signUp`).
enum AuthOperation {
signUp,
signIn,
resend,
requestPasswordReset,
verifyLink,
updatePassword,
}
/// Übersetzt Ausnahmen von `supabase_flutter` in [AuthFailure]
/// (Mapping-Tabelle: Spec #1 § 4.4).
///
/// Befund zu `gotrue` 2.27.3 (A1-Prüfung der Spec): Jede Antwort mit Status
/// >= 500 wird als [AuthRetryableFetchException] geworfen, mit dem rohen Body
/// in `message`, dem Status in `statusCode` und `code == null`. Ein
/// Trigger-Fehler beim `signUp` (HTTP 500) ist deshalb nur am Status
/// erkennbar; die Prüfung muss vor dem Netzwerkfall stehen.
AuthFailure mapAuthError(Object error, AuthOperation operation) {
if (error is AuthWeakPasswordException) {
return AuthFailure.weakPassword(_weakReasons(error.reasons));
}
if (error is AuthRetryableFetchException) {
if (operation == AuthOperation.signUp && error.statusCode == '500') {
return AuthFailure.registrationRejected(
reason: registrationRejectionReasonFrom(error.message),
);
}
// Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall).
return const AuthFailure.network();
}
if (error is AuthSessionMissingException) {
return const AuthFailure.unexpected();
}
if (error is AuthException) {
return _mapApiError(error, operation);
}
if (error is TimeoutException || _isTransportError(error)) {
return const AuthFailure.network();
}
return const AuthFailure.unexpected();
}
AuthFailure _mapApiError(AuthException error, AuthOperation operation) {
switch (error.code) {
case 'invalid_credentials':
return const AuthFailure.invalidCredentials();
case 'email_not_confirmed':
return const AuthFailure.emailNotConfirmed();
case 'over_email_send_rate_limit':
return switch (operation) {
AuthOperation.signUp ||
AuthOperation.requestPasswordReset ||
AuthOperation.resend => const AuthFailure.emailSendLimited(),
_ => const AuthFailure.rateLimited(),
};
case 'over_request_rate_limit':
return const AuthFailure.rateLimited();
case 'weak_password':
return const AuthFailure.weakPassword([]);
case 'same_password':
return const AuthFailure.samePassword();
case 'otp_expired':
return const AuthFailure.linkInvalidOrExpired();
case 'email_address_invalid' || 'validation_failed':
return const AuthFailure.invalidEmail();
}
if (error.statusCode == '429') {
return const AuthFailure.rateLimited();
}
if (operation == AuthOperation.verifyLink && error.statusCode == '403') {
return const AuthFailure.linkInvalidOrExpired();
}
return const AuthFailure.unexpected();
}
List<WeakPasswordReason> _weakReasons(List<String> reasons) => [
for (final reason in reasons)
switch (reason) {
'length' => WeakPasswordReason.length,
'characters' => WeakPasswordReason.characters,
'pwned' => WeakPasswordReason.pwned,
_ => WeakPasswordReason.unknown,
},
];
/// Liest den Grund aus der Trigger-Meldung (Body des HTTP-500). Best effort,
/// das Format ist nicht zugesichert.
RegistrationRejectionReason registrationRejectionReasonFrom(String message) {
if (message.contains('display_name_invalid')) {
return RegistrationRejectionReason.displayName;
}
if (message.contains('age_confirmation_required')) {
return RegistrationRejectionReason.ageConfirmation;
}
if (message.contains('terms_acceptance_required')) {
return RegistrationRejectionReason.termsAcceptance;
}
if (message.contains('terms_version_required')) {
return RegistrationRejectionReason.termsVersion;
}
return RegistrationRejectionReason.unknown;
}
/// Rohe Transportfehler. `gotrue` verpackt Transportfehler normalerweise
/// schon selbst; das hier ist die Rückfallebene. Erkennung per `is`
/// (`ClientException` aus `package:http`, `dart:io`-Fehler über bedingten
/// Import), nicht über `runtimeType`: Das bricht in minifiziertem dart2js.
bool _isTransportError(Object error) =>
error is http.ClientException || isIoNetworkError(error);