Shuffle/app/lib/features/auth/data/auth_status_tracker.dart
cschlaefke 7c2f684098
All checks were successful
CI / flutter (pull_request) Successful in 1m13s
CI / web-build (pull_request) Successful in 55s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 30s
fix(auth): address review findings for password reset
Finish the recovery guard only after the password change, mark the change
in the repository so a late userUpdated event is harmless, keep the form
locked after success, carry from through the reset check-email page,
share the password error texts and focus the continue button.

Refs #1

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

87 lines
3 KiB
Dart

import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
// `AuthUser` heißt auch in supabase_flutter; hier ist die Domänenklasse gemeint.
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
/// Bildet `AuthChangeEvent`s auf [AuthStatus] ab (Spec #1 § 5.5).
///
/// Abweichung von der Wortlaut-Zuordnung in § 5.5: Die Recovery bleibt
/// bestehen, bis [finishRecovery] oder `signedOut` eintrifft. Ein
/// `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard nicht
/// auf. `userUpdated` (Passwort geändert) beendet ihn ebenfalls **nicht**
/// mehr, sondern setzt nur `passwordChanged`: Sonst würde der Router den
/// Nutzer von `/reset-password` wegleiten, bevor die Erfolgsmeldung
/// (KTO-01 Kriterium 9) zu sehen ist. Erst die ausdrückliche Bestätigung des
/// Nutzers beendet den Guard. Der Zustand lebt nur im Prozess
/// (Spec #1 § 5.4, R12): Aus dem `amr`-Claim lässt er sich nicht ableiten.
class AuthStatusTracker {
bool _recovery = false;
bool _passwordChanged = false;
/// Status für [session] unter Berücksichtigung der laufenden Recovery.
AuthStatus statusFor(Session? session) {
final user = session?.user;
if (user == null) {
return const AuthStatus.signedOut();
}
final authUser = AuthUser(
id: user.id,
email: user.email,
emailConfirmed: user.emailConfirmedAt != null,
);
return _recovery
? AuthStatus.passwordRecovery(
authUser,
passwordChanged: _passwordChanged,
)
: AuthStatus.signedIn(authUser);
}
/// Verarbeitet ein Ereignis und liefert den neuen Status.
AuthStatus handle(AuthChangeEvent event, Session? session) {
switch (event) {
case AuthChangeEvent.passwordRecovery:
_recovery = session != null;
_passwordChanged = false;
case AuthChangeEvent.userUpdated:
// The password change itself: stay in the guard (see class doc).
if (_recovery) {
_passwordChanged = true;
}
case AuthChangeEvent.signedOut:
_end();
default:
break;
}
if (session == null) {
_end();
}
return statusFor(session);
}
/// The password was set during a running recovery (successful
/// `updateUser`). Idempotent to the later `userUpdated` event, which gotrue
/// delivers asynchronously, possibly after `updateUser` returned. Without a
/// running recovery nothing changes.
AuthStatus markPasswordChanged(Session? session) {
if (_recovery) {
_passwordChanged = true;
}
return statusFor(session);
}
/// Ends the recovery after the user confirmed the success message. Only
/// possible once the password is changed; before that the status stays
/// unchanged (the guard cannot be skipped).
AuthStatus finishRecovery(Session? session) {
if (_recovery && _passwordChanged) {
_end();
}
return statusFor(session);
}
void _end() {
_recovery = false;
_passwordChanged = false;
}
}