- 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>
118 lines
4.3 KiB
Dart
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);
|