Shuffle/app/lib/features/auth/data/supabase_auth_repository.dart
cschlaefke 87c0d7d1ec feat(auth): add password reset (forgot password, recovery link, new password)
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>
2026-11-03 09:47:03 +01:00

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',
);
}
}