Shuffle/app/lib/features/auth/data/supabase_auth_repository.dart
cschlaefke 54337ffc59 feat(auth): add auth domain, repository, validators and router redirects
Refs #1

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

163 lines
5.2 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) {
final next = _tracker.handle(state.event, state.session);
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
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',
);
}
}