ForgotPasswordScreen with neutral reaction (F12), CheckEmail reset mode on its own path, recovery link handling and ResetPasswordScreen with the recovery guard held until the user confirms the success message. Carries the from parameter between login, register and forgot password. Removes the route placeholders. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
172 lines
5.4 KiB
Dart
172 lines
5.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:shuffle/core/logging/app_logger.dart';
|
|
import 'package:shuffle/core/result/result.dart';
|
|
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
|
|
import 'package:shuffle/features/auth/data/auth_status_tracker.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_failure.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_repository.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_status.dart';
|
|
import 'package:shuffle/features/auth/domain/email_link_type.dart';
|
|
import 'package:shuffle/features/auth/domain/registration_input.dart';
|
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
|
|
|
/// [AuthRepository] gegen Supabase Auth (GoTrue).
|
|
///
|
|
/// Logs enthalten weder E-Mail-Adressen noch Passwörter noch Tokens, nur
|
|
/// Operation, Fehlerart, HTTP-Status und Fehlercode.
|
|
class SupabaseAuthRepository implements AuthRepository {
|
|
SupabaseAuthRepository(this._auth) {
|
|
_lastStatus = _tracker.statusFor(_auth.currentSession);
|
|
_subscription = _auth.onAuthStateChange.listen(
|
|
_onAuthState,
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
_log.warning('auth state stream error: ${error.runtimeType}');
|
|
},
|
|
);
|
|
}
|
|
|
|
final GoTrueClient _auth;
|
|
final _tracker = AuthStatusTracker();
|
|
final _changes = StreamController<AuthStatus>.broadcast(sync: true);
|
|
final _log = AppLogger('AuthRepository');
|
|
late AuthStatus _lastStatus;
|
|
StreamSubscription<AuthState>? _subscription;
|
|
|
|
void _onAuthState(AuthState state) =>
|
|
_publish(_tracker.handle(state.event, state.session));
|
|
|
|
void _publish(AuthStatus next) {
|
|
if (next != _lastStatus) {
|
|
_lastStatus = next;
|
|
_changes.add(next);
|
|
}
|
|
}
|
|
|
|
/// Beendet das Abonnieren der Auth-Ereignisse.
|
|
Future<void> dispose() async {
|
|
await _subscription?.cancel();
|
|
_subscription = null;
|
|
await _changes.close();
|
|
}
|
|
|
|
@override
|
|
AuthStatus get currentStatus => _lastStatus;
|
|
|
|
@override
|
|
Stream<AuthStatus> watchStatus() {
|
|
// ignore: close_sinks, wird vom Abonnenten über cancel geschlossen.
|
|
late StreamController<AuthStatus> controller;
|
|
StreamSubscription<AuthStatus>? inner;
|
|
controller = StreamController<AuthStatus>(
|
|
onListen: () {
|
|
controller.add(_lastStatus);
|
|
inner = _changes.stream.listen(controller.add);
|
|
},
|
|
onCancel: () => inner?.cancel(),
|
|
);
|
|
return controller.stream;
|
|
}
|
|
|
|
@override
|
|
Future<Result<void, AuthFailure>> signUp(RegistrationInput input) => _guard(
|
|
AuthOperation.signUp,
|
|
() => _auth.signUp(
|
|
email: input.email.trim(),
|
|
password: input.password,
|
|
data: <String, dynamic>{
|
|
'display_name': input.displayName.trim(),
|
|
// JSON-Booleans: Der Trigger vergleicht `meta ->> '…'` mit 'true'.
|
|
'age_confirmed': input.ageConfirmed,
|
|
'terms_accepted': input.termsAccepted,
|
|
'terms_version': input.termsVersion,
|
|
'locale': input.locale,
|
|
},
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<Result<void, AuthFailure>> resendConfirmation(String email) => _guard(
|
|
AuthOperation.resend,
|
|
() => _auth.resend(type: OtpType.signup, email: email.trim()),
|
|
);
|
|
|
|
@override
|
|
Future<Result<void, AuthFailure>> signIn(String email, String password) =>
|
|
_guard(
|
|
AuthOperation.signIn,
|
|
() => _auth.signInWithPassword(email: email.trim(), password: password),
|
|
);
|
|
|
|
@override
|
|
Future<Result<void, AuthFailure>> requestPasswordReset(String email) =>
|
|
_guard(
|
|
AuthOperation.requestPasswordReset,
|
|
() => _auth.resetPasswordForEmail(email.trim()),
|
|
);
|
|
|
|
@override
|
|
Future<Result<void, AuthFailure>> verifyEmailLink(
|
|
String tokenHash,
|
|
EmailLinkType type,
|
|
) => _guard(AuthOperation.verifyLink, () async {
|
|
final response = await _auth.verifyOTP(
|
|
tokenHash: tokenHash,
|
|
type: switch (type) {
|
|
EmailLinkType.confirmation => OtpType.email,
|
|
EmailLinkType.recovery => OtpType.recovery,
|
|
},
|
|
);
|
|
if (response.session == null) {
|
|
throw StateError('verifyOTP returned no session');
|
|
}
|
|
});
|
|
|
|
@override
|
|
Future<Result<void, AuthFailure>> setNewPassword(String password) => _guard(
|
|
AuthOperation.updatePassword,
|
|
() => _auth.updateUser(UserAttributes(password: password)),
|
|
);
|
|
|
|
@override
|
|
void finishPasswordRecovery() {
|
|
if (_lastStatus is PasswordRecovery) {
|
|
_publish(_tracker.finishRecovery(_auth.currentSession));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
try {
|
|
await _auth.signOut();
|
|
} on Object catch (error) {
|
|
_log.warning('signOut failed: ${error.runtimeType}');
|
|
}
|
|
}
|
|
|
|
Future<Result<void, AuthFailure>> _guard(
|
|
AuthOperation operation,
|
|
Future<void> Function() action,
|
|
) async {
|
|
try {
|
|
await action();
|
|
return const Result.success(null);
|
|
} on Object catch (error) {
|
|
final failure = mapAuthError(error, operation);
|
|
_logFailure(operation, error, failure);
|
|
return Result.failure(failure);
|
|
}
|
|
}
|
|
|
|
void _logFailure(AuthOperation operation, Object error, AuthFailure failure) {
|
|
final status = error is AuthException ? error.statusCode : null;
|
|
final code = error is AuthException ? error.code : null;
|
|
final reason = failure is RegistrationRejected
|
|
? ' reason=${failure.reason.name}'
|
|
: '';
|
|
_log.warning(
|
|
'${operation.name} failed: ${failure.runtimeType} '
|
|
'error=${error.runtimeType} status=$status code=$code$reason',
|
|
);
|
|
}
|
|
}
|