From 553a2b67cd0e04b04e044ab848257aea1ed4cfad Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 09:47:03 +0100 Subject: [PATCH 1/5] feat(core): let AppBanner disable its action instead of hiding it Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/widgets/app_banner.dart | 8 ++++- app/test/core/widgets/app_banner_test.dart | 35 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app/lib/core/widgets/app_banner.dart b/app/lib/core/widgets/app_banner.dart index 9860f85..cfc1c8f 100644 --- a/app/lib/core/widgets/app_banner.dart +++ b/app/lib/core/widgets/app_banner.dart @@ -17,6 +17,7 @@ class AppBanner extends StatelessWidget { super.key, this.actionLabel, this.onAction, + this.actionEnabled = true, }) : assert( (actionLabel == null) == (onAction == null), 'actionLabel and onAction belong together', @@ -28,6 +29,11 @@ class AppBanner extends StatelessWidget { final String? actionLabel; final VoidCallback? onAction; + /// `false` keeps the action visible and in the focus order but disabled + /// (e.g. while a request runs). Screen readers still find it and hear that + /// it is unavailable, instead of the control vanishing under their focus. + final bool actionEnabled; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -81,7 +87,7 @@ class AppBanner extends StatelessWidget { Align( alignment: AlignmentDirectional.centerEnd, child: TextButton( - onPressed: onAction, + onPressed: actionEnabled ? onAction : null, style: TextButton.styleFrom(foregroundColor: foreground), child: Text(actionLabel!), ), diff --git a/app/test/core/widgets/app_banner_test.dart b/app/test/core/widgets/app_banner_test.dart index b3deaab..4a5f02f 100644 --- a/app/test/core/widgets/app_banner_test.dart +++ b/app/test/core/widgets/app_banner_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Tristate; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/core/widgets/app_banner.dart'; @@ -46,6 +48,39 @@ void main() { handle.dispose(); }); + testWidgets('a disabled action stays visible and announced as disabled', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + var calls = 0; + await pumpWidgetUnderTest( + tester, + AppBanner( + type: AppBannerType.info, + typeLabel: 'Note', + message: 'Confirm first', + actionLabel: 'Resend', + onAction: () => calls++, + actionEnabled: false, + ), + ); + + final action = find.widgetWithText(TextButton, 'Resend'); + expect(action, findsOneWidget); + expect(tester.widget(action).onPressed, isNull); + expect( + tester + .getSemantics(action) + .getSemanticsData() + .flagsCollection + .isEnabled, + Tristate.isFalse, + ); + await tester.tap(action, warnIfMissed: false); + expect(calls, 0); + handle.dispose(); + }); + testWidgets('action calls back and is at least 48 dp', (tester) async { var calls = 0; await pumpWidgetUnderTest( -- 2.45.2 From 87c0d7d1ec956bb2d26f5a6a7e0ce0d6893e722c Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 09:47:03 +0100 Subject: [PATCH 2/5] 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 --- app/lib/core/routing/app_routes.dart | 1 + .../auth/data/auth_status_tracker.dart | 41 +- .../auth/data/supabase_auth_repository.dart | 13 +- .../features/auth/domain/auth_repository.dart | 4 + app/lib/features/auth/domain/auth_status.dart | 5 +- .../auth/domain/auth_status.freezed.dart | 28 +- .../presentation/auth_route_placeholder.dart | 19 - .../auth/presentation/auth_routes.dart | 29 +- .../check_email/check_email_screen.dart | 49 ++- .../forgot_password_controller.dart | 72 ++++ .../forgot_password_controller.g.dart | 81 ++++ .../forgot_password_screen.dart | 144 +++++++ .../forgot_password_state.dart | 43 +++ .../forgot_password_state.freezed.dart | 317 ++++++++++++++++ .../auth/presentation/login/login_screen.dart | 22 +- .../presentation/providers/auth_redirect.dart | 13 + .../register/register_screen.dart | 12 +- .../reset_password_controller.dart | 135 +++++++ .../reset_password_controller.g.dart | 126 +++++++ .../reset_password/reset_password_screen.dart | 207 ++++++++++ .../reset_password/reset_password_state.dart | 55 +++ .../reset_password_state.freezed.dart | 336 +++++++++++++++++ app/lib/l10n/app_de.arb | 12 +- app/lib/l10n/app_en.arb | 12 +- app/lib/l10n/app_localizations.dart | 60 +++ app/lib/l10n/app_localizations_de.dart | 33 ++ app/lib/l10n/app_localizations_en.dart | 34 ++ .../auth/data/auth_status_tracker_test.dart | 49 ++- .../data/supabase_auth_repository_test.dart | 48 ++- .../auth/presentation/auth_redirect_test.dart | 31 ++ .../auth/presentation/auth_router_test.dart | 165 ++++++-- .../check_email/check_email_screen_test.dart | 91 +++++ .../email_link_controller_test.dart | 26 ++ .../email_link/email_link_screen_test.dart | 63 ++++ .../forgot_password_controller_test.dart | 134 +++++++ .../forgot_password_screen_test.dart | 282 ++++++++++++++ .../presentation/login/login_screen_test.dart | 62 ++- .../reset_password_controller_test.dart | 218 +++++++++++ .../reset_password_screen_test.dart | 357 ++++++++++++++++++ docs/design/auth-screens.md | 50 ++- docs/specs/1-auth-registrierung-anmeldung.md | 2 + 41 files changed, 3371 insertions(+), 110 deletions(-) delete mode 100644 app/lib/features/auth/presentation/auth_route_placeholder.dart create mode 100644 app/lib/features/auth/presentation/forgot_password/forgot_password_controller.dart create mode 100644 app/lib/features/auth/presentation/forgot_password/forgot_password_controller.g.dart create mode 100644 app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart create mode 100644 app/lib/features/auth/presentation/forgot_password/forgot_password_state.dart create mode 100644 app/lib/features/auth/presentation/forgot_password/forgot_password_state.freezed.dart create mode 100644 app/lib/features/auth/presentation/reset_password/reset_password_controller.dart create mode 100644 app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart create mode 100644 app/lib/features/auth/presentation/reset_password/reset_password_screen.dart create mode 100644 app/lib/features/auth/presentation/reset_password/reset_password_state.dart create mode 100644 app/lib/features/auth/presentation/reset_password/reset_password_state.freezed.dart create mode 100644 app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart create mode 100644 app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart create mode 100644 app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart create mode 100644 app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart diff --git a/app/lib/core/routing/app_routes.dart b/app/lib/core/routing/app_routes.dart index 9aa40c9..64831b6 100644 --- a/app/lib/core/routing/app_routes.dart +++ b/app/lib/core/routing/app_routes.dart @@ -7,6 +7,7 @@ abstract final class AppRoutes { static const register = '/register'; static const registerCheckEmail = '/register/check-email'; static const forgotPassword = '/forgot-password'; + static const forgotPasswordCheckEmail = '/forgot-password/check-email'; static const authConfirm = '/auth/confirm'; static const resetPassword = '/reset-password'; diff --git a/app/lib/features/auth/data/auth_status_tracker.dart b/app/lib/features/auth/data/auth_status_tracker.dart index 397c261..cf2359c 100644 --- a/app/lib/features/auth/data/auth_status_tracker.dart +++ b/app/lib/features/auth/data/auth_status_tracker.dart @@ -6,12 +6,17 @@ 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 `userUpdated` (Passwort geändert) oder `signedOut` eintrifft. -/// Ein `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard -/// nicht auf. Der Zustand lebt nur im Prozess (Spec #1 § 5.4, R12): Aus dem -/// `amr`-Claim lässt er sich nicht ableiten. +/// 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) { @@ -25,7 +30,10 @@ class AuthStatusTracker { emailConfirmed: user.emailConfirmedAt != null, ); return _recovery - ? AuthStatus.passwordRecovery(authUser) + ? AuthStatus.passwordRecovery( + authUser, + passwordChanged: _passwordChanged, + ) : AuthStatus.signedIn(authUser); } @@ -34,14 +42,31 @@ class AuthStatusTracker { switch (event) { case AuthChangeEvent.passwordRecovery: _recovery = session != null; - case AuthChangeEvent.signedOut || AuthChangeEvent.userUpdated: - _recovery = false; + _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) { - _recovery = false; + _end(); } return statusFor(session); } + + /// Ends the recovery after the user confirmed the success message. + AuthStatus finishRecovery(Session? session) { + _end(); + return statusFor(session); + } + + void _end() { + _recovery = false; + _passwordChanged = false; + } } diff --git a/app/lib/features/auth/data/supabase_auth_repository.dart b/app/lib/features/auth/data/supabase_auth_repository.dart index fec916c..0e81178 100644 --- a/app/lib/features/auth/data/supabase_auth_repository.dart +++ b/app/lib/features/auth/data/supabase_auth_repository.dart @@ -33,8 +33,10 @@ class SupabaseAuthRepository implements AuthRepository { late AuthStatus _lastStatus; StreamSubscription? _subscription; - void _onAuthState(AuthState state) { - final next = _tracker.handle(state.event, state.session); + void _onAuthState(AuthState state) => + _publish(_tracker.handle(state.event, state.session)); + + void _publish(AuthStatus next) { if (next != _lastStatus) { _lastStatus = next; _changes.add(next); @@ -126,6 +128,13 @@ class SupabaseAuthRepository implements AuthRepository { () => _auth.updateUser(UserAttributes(password: password)), ); + @override + void finishPasswordRecovery() { + if (_lastStatus is PasswordRecovery) { + _publish(_tracker.finishRecovery(_auth.currentSession)); + } + } + @override Future signOut() async { try { diff --git a/app/lib/features/auth/domain/auth_repository.dart b/app/lib/features/auth/domain/auth_repository.dart index 0737d5b..d885981 100644 --- a/app/lib/features/auth/domain/auth_repository.dart +++ b/app/lib/features/auth/domain/auth_repository.dart @@ -29,6 +29,10 @@ abstract interface class AuthRepository { Future> setNewPassword(String password); + /// Beendet den Recovery-Guard nach erfolgreich gesetztem Passwort (die + /// Sitzung bleibt bestehen). Ohne laufende Recovery ohne Wirkung. + void finishPasswordRecovery(); + /// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur /// geloggt. Future signOut(); diff --git a/app/lib/features/auth/domain/auth_status.dart b/app/lib/features/auth/domain/auth_status.dart index 6f6588b..9fb6819 100644 --- a/app/lib/features/auth/domain/auth_status.dart +++ b/app/lib/features/auth/domain/auth_status.dart @@ -14,7 +14,10 @@ sealed class AuthStatus with _$AuthStatus { const factory AuthStatus.signedOut() = SignedOut; const factory AuthStatus.signedIn(AuthUser user) = SignedIn; - const factory AuthStatus.passwordRecovery(AuthUser user) = PasswordRecovery; + const factory AuthStatus.passwordRecovery( + AuthUser user, { + @Default(false) bool passwordChanged, + }) = PasswordRecovery; bool get isSignedOut => this is SignedOut; bool get isPasswordRecovery => this is PasswordRecovery; diff --git a/app/lib/features/auth/domain/auth_status.freezed.dart b/app/lib/features/auth/domain/auth_status.freezed.dart index 0bf1664..f3144ce 100644 --- a/app/lib/features/auth/domain/auth_status.freezed.dart +++ b/app/lib/features/auth/domain/auth_status.freezed.dart @@ -123,12 +123,12 @@ return passwordRecovery(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function()? signedOut,TResult Function( AuthUser user)? signedIn,TResult Function( AuthUser user)? passwordRecovery,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function()? signedOut,TResult Function( AuthUser user)? signedIn,TResult Function( AuthUser user, bool passwordChanged)? passwordRecovery,required TResult orElse(),}) {final _that = this; switch (_that) { case SignedOut() when signedOut != null: return signedOut();case SignedIn() when signedIn != null: return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null: -return passwordRecovery(_that.user);case _: +return passwordRecovery(_that.user,_that.passwordChanged);case _: return orElse(); } @@ -146,12 +146,12 @@ return passwordRecovery(_that.user);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function() signedOut,required TResult Function( AuthUser user) signedIn,required TResult Function( AuthUser user) passwordRecovery,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function() signedOut,required TResult Function( AuthUser user) signedIn,required TResult Function( AuthUser user, bool passwordChanged) passwordRecovery,}) {final _that = this; switch (_that) { case SignedOut(): return signedOut();case SignedIn(): return signedIn(_that.user);case PasswordRecovery(): -return passwordRecovery(_that.user);} +return passwordRecovery(_that.user,_that.passwordChanged);} } /// A variant of `when` that fallback to returning `null` /// @@ -165,12 +165,12 @@ return passwordRecovery(_that.user);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function()? signedOut,TResult? Function( AuthUser user)? signedIn,TResult? Function( AuthUser user)? passwordRecovery,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? signedOut,TResult? Function( AuthUser user)? signedIn,TResult? Function( AuthUser user, bool passwordChanged)? passwordRecovery,}) {final _that = this; switch (_that) { case SignedOut() when signedOut != null: return signedOut();case SignedIn() when signedIn != null: return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null: -return passwordRecovery(_that.user);case _: +return passwordRecovery(_that.user,_that.passwordChanged);case _: return null; } @@ -291,10 +291,11 @@ $AuthUserCopyWith<$Res> get user { class PasswordRecovery extends AuthStatus { - const PasswordRecovery(this.user): super._(); + const PasswordRecovery(this.user, {this.passwordChanged = false}): super._(); final AuthUser user; +@JsonKey() final bool passwordChanged; /// Create a copy of AuthStatus /// with the given fields replaced by the non-null parameter values. @@ -306,18 +307,18 @@ $PasswordRecoveryCopyWith get copyWith => _$PasswordRecoveryCo @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordRecovery&&(identical(other.user, user) || other.user == user)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordRecovery&&(identical(other.user, user) || other.user == user)&&(identical(other.passwordChanged, passwordChanged) || other.passwordChanged == passwordChanged)); } @override int get hashCode { - return Object.hash(runtimeType,user); + return Object.hash(runtimeType,user,passwordChanged); } @override String toString() { - return 'AuthStatus.passwordRecovery(user: $user)'; + return 'AuthStatus.passwordRecovery(user: $user, passwordChanged: $passwordChanged)'; } @@ -328,7 +329,7 @@ abstract mixin class $PasswordRecoveryCopyWith<$Res> implements $AuthStatusCopyW factory $PasswordRecoveryCopyWith(PasswordRecovery value, $Res Function(PasswordRecovery) _then) = _$PasswordRecoveryCopyWithImpl; @useResult $Res call({ - AuthUser user + AuthUser user, bool passwordChanged }); @@ -345,10 +346,11 @@ class _$PasswordRecoveryCopyWithImpl<$Res> /// Create a copy of AuthStatus /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? user = null,}) { +@pragma('vm:prefer-inline') $Res call({Object? user = null,Object? passwordChanged = null,}) { return _then(PasswordRecovery( null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable -as AuthUser, +as AuthUser,passwordChanged: null == passwordChanged ? _self.passwordChanged : passwordChanged // ignore: cast_nullable_to_non_nullable +as bool, )); } diff --git a/app/lib/features/auth/presentation/auth_route_placeholder.dart b/app/lib/features/auth/presentation/auth_route_placeholder.dart deleted file mode 100644 index 0da02a7..0000000 --- a/app/lib/features/auth/presentation/auth_route_placeholder.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -/// Placeholder for auth pages that 01b and 01c still deliver. Without visible -/// texts (no hard-coded UI strings). -class AuthRoutePlaceholder extends StatelessWidget { - const AuthRoutePlaceholder({required this.routeKey, super.key}); - - final String routeKey; - - @override - Widget build(BuildContext context) => - Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox()); -} - -GoRoute authPlaceholderRoute(String path, String key) => GoRoute( - path: path, - builder: (context, state) => AuthRoutePlaceholder(routeKey: key), -); diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart index f6fe2c7..6b39748 100644 --- a/app/lib/features/auth/presentation/auth_routes.dart +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -1,13 +1,13 @@ import 'package:go_router/go_router.dart'; import 'package:shuffle/core/routing/app_routes.dart'; -import 'package:shuffle/features/auth/presentation/auth_route_placeholder.dart'; import 'package:shuffle/features/auth/presentation/check_email/check_email_screen.dart'; import 'package:shuffle/features/auth/presentation/email_link/email_link_screen.dart'; +import 'package:shuffle/features/auth/presentation/forgot_password/forgot_password_screen.dart'; import 'package:shuffle/features/auth/presentation/login/login_screen.dart'; import 'package:shuffle/features/auth/presentation/register/register_screen.dart'; +import 'package:shuffle/features/auth/presentation/reset_password/reset_password_screen.dart'; -/// Routes of the auth pages (Spec #1 § 5.3). Forgot and reset password are -/// placeholders until 01c. +/// Routes of the auth pages (Spec #1 § 5.3). final List authRoutes = [ GoRoute( path: AppRoutes.login, @@ -16,7 +16,8 @@ final List authRoutes = [ ), GoRoute( path: AppRoutes.register, - builder: (context, state) => const RegisterScreen(), + builder: (context, state) => + RegisterScreen(from: state.uri.queryParameters[AppRoutes.fromParam]), routes: [ GoRoute( path: 'check-email', @@ -31,7 +32,20 @@ final List authRoutes = [ ), ], ), - authPlaceholderRoute(AppRoutes.forgotPassword, 'forgot_password'), + GoRoute( + path: AppRoutes.forgotPassword, + builder: (context, state) => ForgotPasswordScreen( + from: state.uri.queryParameters[AppRoutes.fromParam], + ), + routes: [ + GoRoute( + path: 'check-email', + // The mode is part of the path, so it survives a web reload. + builder: (context, state) => + const CheckEmailScreen(mode: CheckEmailMode.reset), + ), + ], + ), GoRoute( path: AppRoutes.authConfirm, builder: (context, state) => EmailLinkScreen( @@ -39,5 +53,8 @@ final List authRoutes = [ type: state.uri.queryParameters['type'], ), ), - authPlaceholderRoute(AppRoutes.resetPassword, 'reset_password'), + GoRoute( + path: AppRoutes.resetPassword, + builder: (context, state) => const ResetPasswordScreen(), + ), ]; diff --git a/app/lib/features/auth/presentation/check_email/check_email_screen.dart b/app/lib/features/auth/presentation/check_email/check_email_screen.dart index 5b532cc..be28d14 100644 --- a/app/lib/features/auth/presentation/check_email/check_email_screen.dart +++ b/app/lib/features/auth/presentation/check_email/check_email_screen.dart @@ -14,6 +14,17 @@ import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; +/// Which flow the "check your inbox" page belongs to. The mode comes from the +/// route (not from `extra`), so it survives a web reload. +enum CheckEmailMode { + /// After the registration: confirmation link, "resend" available. + confirmation, + + /// After "forgot password": reset link, no resend (Spec #1 § 5.4). The user + /// can enter another address instead. + reset, +} + /// "Check your inbox" after the registration (`/register/check-email`, /// KTO-01 Kriterium 1, 4 und 6). /// @@ -21,9 +32,14 @@ import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dar /// account (Kriterium 4). [email] comes from the router `extra`; after a web /// reload it is missing and the user types it in for "resend". class CheckEmailScreen extends ConsumerStatefulWidget { - const CheckEmailScreen({this.email, super.key}); + const CheckEmailScreen({ + this.email, + this.mode = CheckEmailMode.confirmation, + super.key, + }); final String? email; + final CheckEmailMode mode; @override ConsumerState createState() => _CheckEmailScreenState(); @@ -32,6 +48,8 @@ class CheckEmailScreen extends ConsumerStatefulWidget { class _CheckEmailScreenState extends ConsumerState { final _emailController = TextEditingController(); + bool get _isReset => widget.mode == CheckEmailMode.reset; + bool get _hasKnownEmail => (widget.email ?? '').trim().isNotEmpty; @override @@ -62,7 +80,7 @@ class _CheckEmailScreenState extends ConsumerState { AuthScreenTitle(l10n.authCheckEmailTitle), const SizedBox(height: AppSpacing.lg), Text( - l10n.authCheckEmailBody, + _isReset ? l10n.authCheckEmailBodyReset : l10n.authCheckEmailBody, key: const Key('check_email_body'), style: theme.textTheme.bodyLarge, ), @@ -78,7 +96,7 @@ class _CheckEmailScreenState extends ConsumerState { key: const Key('check_email_delay_hint'), style: theme.textTheme.bodyMedium, ), - if (state.sent) ...[ + if (!_isReset && state.sent) ...[ const SizedBox(height: AppSpacing.lg), AppBanner( key: const Key('check_email_resent_banner'), @@ -87,7 +105,7 @@ class _CheckEmailScreenState extends ConsumerState { message: l10n.authCheckEmailResent, ), ], - if (failure != null) ...[ + if (!_isReset && failure != null) ...[ const SizedBox(height: AppSpacing.lg), AppBanner( key: const Key('check_email_error_banner'), @@ -96,7 +114,7 @@ class _CheckEmailScreenState extends ConsumerState { message: authFailureMessage(l10n, failure), ), ], - if (!_hasKnownEmail) ...[ + if (!_isReset && !_hasKnownEmail) ...[ const SizedBox(height: AppSpacing.xl), AppTextField( key: const Key('check_email_email'), @@ -117,13 +135,20 @@ class _CheckEmailScreenState extends ConsumerState { ), ], const SizedBox(height: AppSpacing.xl), - AppPrimaryButton( - key: const Key('check_email_resend'), - label: l10n.authCheckEmailResend, - loadingLabel: l10n.authSubmitting, - isLoading: state.isSending, - onPressed: _resend, - ), + if (_isReset) + TextButton( + key: const Key('check_email_different_address'), + onPressed: () => context.go(AppRoutes.forgotPassword), + child: Text(l10n.authCheckEmailDifferentAddress), + ) + else + AppPrimaryButton( + key: const Key('check_email_resend'), + label: l10n.authCheckEmailResend, + loadingLabel: l10n.authSubmitting, + isLoading: state.isSending, + onPressed: _resend, + ), const SizedBox(height: AppSpacing.sm), TextButton( key: const Key('check_email_back_to_login'), diff --git a/app/lib/features/auth/presentation/forgot_password/forgot_password_controller.dart b/app/lib/features/auth/presentation/forgot_password/forgot_password_controller.dart new file mode 100644 index 0000000..b1f9635 --- /dev/null +++ b/app/lib/features/auth/presentation/forgot_password/forgot_password_controller.dart @@ -0,0 +1,72 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/resend_origin.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; +import 'package:shuffle/features/auth/presentation/forgot_password/forgot_password_state.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; + +part 'forgot_password_controller.g.dart'; + +/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12). +/// +/// The reaction is the same for known and unknown addresses: every 200 +/// response, and `emailSendLimited`, count as success. +@riverpod +class ForgotPasswordController extends _$ForgotPasswordController { + @override + ForgotPasswordState build() => const ForgotPasswordState(); + + void emailChanged(String value) { + state = state.copyWith(email: value, serverEmailInvalid: false); + } + + /// Field left: show its error. + void emailBlurred() { + state = state.copyWith(emailVisible: true); + } + + /// Validates and requests the reset mail. Returns `true` if the screen + /// should continue to "check your inbox" (every 200 response, and + /// `emailSendLimited`, F12). + Future submit() async { + if (state.isSubmitting) { + return false; + } + state = state.copyWith( + status: ForgotPasswordStatus.submitting, + emailVisible: true, + bannerFailure: null, + serverEmailInvalid: false, + ); + if (state.emailError != null) { + state = state.copyWith( + status: ForgotPasswordStatus.editing, + focusRequest: state.focusRequest + 1, + ); + return false; + } + + final email = EmailFormat.normalize(state.email); + final result = await ref + .read(authRepositoryProvider) + .requestPasswordReset(email); + if (!ref.mounted) { + return false; + } + state = state.copyWith(status: ForgotPasswordStatus.editing); + final failure = result.errorOrNull; + if (failure == null || openEmailInputFailureFor(failure) == null) { + return true; + } + switch (failure) { + case InvalidEmail(): + state = state.copyWith( + serverEmailInvalid: true, + focusRequest: state.focusRequest + 1, + ); + default: + state = state.copyWith(bannerFailure: failure); + } + return false; + } +} diff --git a/app/lib/features/auth/presentation/forgot_password/forgot_password_controller.g.dart b/app/lib/features/auth/presentation/forgot_password/forgot_password_controller.g.dart new file mode 100644 index 0000000..1ec491b --- /dev/null +++ b/app/lib/features/auth/presentation/forgot_password/forgot_password_controller.g.dart @@ -0,0 +1,81 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'forgot_password_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12). +/// +/// The reaction is the same for known and unknown addresses: every 200 +/// response, and `emailSendLimited`, count as success. + +@ProviderFor(ForgotPasswordController) +final forgotPasswordControllerProvider = ForgotPasswordControllerProvider._(); + +/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12). +/// +/// The reaction is the same for known and unknown addresses: every 200 +/// response, and `emailSendLimited`, count as success. +final class ForgotPasswordControllerProvider + extends $NotifierProvider { + /// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12). + /// + /// The reaction is the same for known and unknown addresses: every 200 + /// response, and `emailSendLimited`, count as success. + ForgotPasswordControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'forgotPasswordControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$forgotPasswordControllerHash(); + + @$internal + @override + ForgotPasswordController create() => ForgotPasswordController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ForgotPasswordState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$forgotPasswordControllerHash() => + r'd936df23ac46d099923c01f836a978236fc69843'; + +/// Logic of the "forgot password" form (KTO-01 Kriterium 8, 4, F12). +/// +/// The reaction is the same for known and unknown addresses: every 200 +/// response, and `emailSendLimited`, count as success. + +abstract class _$ForgotPasswordController + extends $Notifier { + ForgotPasswordState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ForgotPasswordState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart b/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart new file mode 100644 index 0000000..72e85dc --- /dev/null +++ b/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart @@ -0,0 +1,144 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/forgot_password/forgot_password_controller.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// Request a password reset link (`/forgot-password`, KTO-01 Kriterium 8). +/// Only shows state and forwards input to the [ForgotPasswordController]. +/// [from] is carried back to the sign-in page. +class ForgotPasswordScreen extends ConsumerStatefulWidget { + const ForgotPasswordScreen({this.from, super.key}); + + final String? from; + + @override + ConsumerState createState() => + _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends ConsumerState { + final _email = TextEditingController(); + final _emailFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _emailFocus.addListener(() { + if (!_emailFocus.hasFocus) { + ref.read(forgotPasswordControllerProvider.notifier).emailBlurred(); + } + }); + } + + @override + void dispose() { + _email.dispose(); + _emailFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + // The address stays out of the route: the reset page does not need it. + final succeeded = await ref + .read(forgotPasswordControllerProvider.notifier) + .submit(); + if (!succeeded || !mounted) { + return; + } + context.go(AppRoutes.forgotPasswordCheckEmail); + } + + @override + Widget build(BuildContext context) { + ref.listen(forgotPasswordControllerProvider, (previous, next) { + if (previous?.focusRequest != next.focusRequest) { + _emailFocus.requestFocus(); + } + }); + final state = ref.watch(forgotPasswordControllerProvider); + final controller = ref.read(forgotPasswordControllerProvider.notifier); + final l10n = context.l10n; + final busy = state.isSubmitting; + final failure = state.bannerFailure; + + return AppFormScaffold( + key: const Key('forgot_password_screen'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authForgotTitle), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.authForgotBody, + key: const Key('forgot_password_body'), + style: Theme.of(context).textTheme.bodyLarge, + ), + if (failure != null) ...[ + const SizedBox(height: AppSpacing.lg), + AppBanner( + key: const Key('forgot_password_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + actionLabel: failure.isRetryable ? l10n.authActionRetry : null, + onAction: failure.isRetryable ? () => unawaited(_submit()) : null, + actionEnabled: !busy, + ), + ], + const SizedBox(height: AppSpacing.xl), + AppTextField( + key: const Key('forgot_password_email'), + label: l10n.authFieldEmailLabel, + controller: _email, + focusNode: _emailFocus, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.email], + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + errorText: state.shownEmailError + ? l10n.authErrorEmailInvalid + : null, + onChanged: controller.emailChanged, + onSubmitted: (_) { + if (!busy) { + unawaited(_submit()); + } + }, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('forgot_password_submit'), + label: l10n.authForgotSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: busy, + onPressed: _submit, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('forgot_password_back'), + onPressed: busy + ? null + : () => context.go( + authLocation(AppRoutes.login, from: widget.from), + ), + child: Text(l10n.authBackToLogin), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/auth/presentation/forgot_password/forgot_password_state.dart b/app/lib/features/auth/presentation/forgot_password/forgot_password_state.dart new file mode 100644 index 0000000..127afa0 --- /dev/null +++ b/app/lib/features/auth/presentation/forgot_password/forgot_password_state.dart @@ -0,0 +1,43 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/email_format.dart'; + +part 'forgot_password_state.freezed.dart'; + +enum ForgotPasswordStatus { editing, submitting } + +/// Value, error visibility and submit state of the "forgot password" form. +@freezed +abstract class ForgotPasswordState with _$ForgotPasswordState { + const factory ForgotPasswordState({ + @Default('') String email, + + /// The email error is shown (field left or submit attempted). + @Default(false) bool emailVisible, + @Default(ForgotPasswordStatus.editing) ForgotPasswordStatus status, + + /// General failure shown as banner (network, rate limit, unexpected). + AuthFailure? bannerFailure, + + /// The server rejected the address syntax; shown at the field. + @Default(false) bool serverEmailInvalid, + + /// Incremented when the email field should receive focus. + @Default(0) int focusRequest, + }) = _ForgotPasswordState; + + const ForgotPasswordState._(); + + /// No personal data in logs or error reports. + @override + String toString() => + 'ForgotPasswordState(email: , status: $status, ' + 'bannerFailure: $bannerFailure)'; + + bool get isSubmitting => status == ForgotPasswordStatus.submitting; + + EmailError? get emailError => EmailFormat.validate(email); + + bool get shownEmailError => + serverEmailInvalid || (emailVisible && emailError != null); +} diff --git a/app/lib/features/auth/presentation/forgot_password/forgot_password_state.freezed.dart b/app/lib/features/auth/presentation/forgot_password/forgot_password_state.freezed.dart new file mode 100644 index 0000000..0903db1 --- /dev/null +++ b/app/lib/features/auth/presentation/forgot_password/forgot_password_state.freezed.dart @@ -0,0 +1,317 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'forgot_password_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ForgotPasswordState { + + String get email;/// The email error is shown (field left or submit attempted). + bool get emailVisible; ForgotPasswordStatus get status;/// General failure shown as banner (network, rate limit, unexpected). + AuthFailure? get bannerFailure;/// The server rejected the address syntax; shown at the field. + bool get serverEmailInvalid;/// Incremented when the email field should receive focus. + int get focusRequest; +/// Create a copy of ForgotPasswordState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ForgotPasswordStateCopyWith get copyWith => _$ForgotPasswordStateCopyWithImpl(this as ForgotPasswordState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as ForgotPasswordState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is ForgotPasswordState&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.emailVisible, _this.emailVisible) || other.emailVisible == _this.emailVisible)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&(identical(other.serverEmailInvalid, _this.serverEmailInvalid) || other.serverEmailInvalid == _this.serverEmailInvalid)&&(identical(other.focusRequest, _this.focusRequest) || other.focusRequest == _this.focusRequest)); +} + + +@override +int get hashCode { + final _this = this as ForgotPasswordState; + return Object.hash(runtimeType,_this.email,_this.emailVisible,_this.status,_this.bannerFailure,_this.serverEmailInvalid,_this.focusRequest); +} + + + +} + +/// @nodoc +abstract mixin class $ForgotPasswordStateCopyWith<$Res> { + factory $ForgotPasswordStateCopyWith(ForgotPasswordState value, $Res Function(ForgotPasswordState) _then) = _$ForgotPasswordStateCopyWithImpl; +@useResult +$Res call({ + String email, bool emailVisible, ForgotPasswordStatus status, AuthFailure? bannerFailure, bool serverEmailInvalid, int focusRequest +}); + + +$AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class _$ForgotPasswordStateCopyWithImpl<$Res> + implements $ForgotPasswordStateCopyWith<$Res> { + _$ForgotPasswordStateCopyWithImpl(this._self, this._then); + + final ForgotPasswordState _self; + final $Res Function(ForgotPasswordState) _then; + +/// Create a copy of ForgotPasswordState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? emailVisible = null,Object? status = null,Object? bannerFailure = freezed,Object? serverEmailInvalid = null,Object? focusRequest = null,}) { + return _then(ForgotPasswordState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,emailVisible: null == emailVisible ? _self.emailVisible : emailVisible // ignore: cast_nullable_to_non_nullable +as bool,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as ForgotPasswordStatus,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable +as bool,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of ForgotPasswordState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ForgotPasswordState]. +extension ForgotPasswordStatePatterns on ForgotPasswordState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ForgotPasswordState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ForgotPasswordState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ForgotPasswordState value) $default,){ +final _that = this; +switch (_that) { +case _ForgotPasswordState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ForgotPasswordState value)? $default,){ +final _that = this; +switch (_that) { +case _ForgotPasswordState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String email, bool emailVisible, ForgotPasswordStatus status, AuthFailure? bannerFailure, bool serverEmailInvalid, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ForgotPasswordState() when $default != null: +return $default(_that.email,_that.emailVisible,_that.status,_that.bannerFailure,_that.serverEmailInvalid,_that.focusRequest);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String email, bool emailVisible, ForgotPasswordStatus status, AuthFailure? bannerFailure, bool serverEmailInvalid, int focusRequest) $default,) {final _that = this; +switch (_that) { +case _ForgotPasswordState(): +return $default(_that.email,_that.emailVisible,_that.status,_that.bannerFailure,_that.serverEmailInvalid,_that.focusRequest);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, bool emailVisible, ForgotPasswordStatus status, AuthFailure? bannerFailure, bool serverEmailInvalid, int focusRequest)? $default,) {final _that = this; +switch (_that) { +case _ForgotPasswordState() when $default != null: +return $default(_that.email,_that.emailVisible,_that.status,_that.bannerFailure,_that.serverEmailInvalid,_that.focusRequest);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ForgotPasswordState extends ForgotPasswordState { + const _ForgotPasswordState({this.email = '', this.emailVisible = false, this.status = ForgotPasswordStatus.editing, this.bannerFailure, this.serverEmailInvalid = false, this.focusRequest = 0}): super._(); + + +@override@JsonKey() final String email; +/// The email error is shown (field left or submit attempted). +@override@JsonKey() final bool emailVisible; +@override@JsonKey() final ForgotPasswordStatus status; +/// General failure shown as banner (network, rate limit, unexpected). +@override final AuthFailure? bannerFailure; +/// The server rejected the address syntax; shown at the field. +@override@JsonKey() final bool serverEmailInvalid; +/// Incremented when the email field should receive focus. +@override@JsonKey() final int focusRequest; + +/// Create a copy of ForgotPasswordState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ForgotPasswordStateCopyWith<_ForgotPasswordState> get copyWith => __$ForgotPasswordStateCopyWithImpl<_ForgotPasswordState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ForgotPasswordState&&(identical(other.email, email) || other.email == email)&&(identical(other.emailVisible, emailVisible) || other.emailVisible == emailVisible)&&(identical(other.status, status) || other.status == status)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&(identical(other.serverEmailInvalid, serverEmailInvalid) || other.serverEmailInvalid == serverEmailInvalid)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,email,emailVisible,status,bannerFailure,serverEmailInvalid,focusRequest); +} + + + +} + +/// @nodoc +abstract mixin class _$ForgotPasswordStateCopyWith<$Res> implements $ForgotPasswordStateCopyWith<$Res> { + factory _$ForgotPasswordStateCopyWith(_ForgotPasswordState value, $Res Function(_ForgotPasswordState) _then) = __$ForgotPasswordStateCopyWithImpl; +@override @useResult +$Res call({ + String email, bool emailVisible, ForgotPasswordStatus status, AuthFailure? bannerFailure, bool serverEmailInvalid, int focusRequest +}); + + +@override $AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class __$ForgotPasswordStateCopyWithImpl<$Res> + implements _$ForgotPasswordStateCopyWith<$Res> { + __$ForgotPasswordStateCopyWithImpl(this._self, this._then); + + final _ForgotPasswordState _self; + final $Res Function(_ForgotPasswordState) _then; + +/// Create a copy of ForgotPasswordState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? emailVisible = null,Object? status = null,Object? bannerFailure = freezed,Object? serverEmailInvalid = null,Object? focusRequest = null,}) { + return _then(_ForgotPasswordState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,emailVisible: null == emailVisible ? _self.emailVisible : emailVisible // ignore: cast_nullable_to_non_nullable +as bool,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as ForgotPasswordStatus,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,serverEmailInvalid: null == serverEmailInvalid ? _self.serverEmailInvalid : serverEmailInvalid // ignore: cast_nullable_to_non_nullable +as bool,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of ForgotPasswordState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/login/login_screen.dart b/app/lib/features/auth/presentation/login/login_screen.dart index ef4189b..812acc4 100644 --- a/app/lib/features/auth/presentation/login/login_screen.dart +++ b/app/lib/features/auth/presentation/login/login_screen.dart @@ -17,6 +17,7 @@ import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; import 'package:shuffle/features/auth/presentation/login/login_controller.dart'; import 'package:shuffle/features/auth/presentation/login/login_state.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; /// Sign-in (`/login`, KTO-01 Kriterien 5, 6 und 7). Only shows state and @@ -118,12 +119,11 @@ class _LoginScreenState extends ConsumerState { type: AppBannerType.info, typeLabel: l10n.authBannerTypeInfo, message: l10n.authErrorEmailNotConfirmed, - actionLabel: resend.isSending - ? null - : l10n.authActionResendConfirmation, - onAction: resend.isSending - ? null - : () => unawaited(controller.resendConfirmation()), + actionLabel: l10n.authActionResendConfirmation, + onAction: () => unawaited(controller.resendConfirmation()), + // Stays visible while sending, only disabled (no double + // send, no vanishing control under screen reader focus). + actionEnabled: !resend.isSending, ) else _FailureBanner( @@ -206,12 +206,18 @@ class _LoginScreenState extends ConsumerState { key: const Key('login_forgot_link'), onPressed: busy ? null - : () => context.go(AppRoutes.forgotPassword), + : () => context.go( + authLocation(AppRoutes.forgotPassword, from: widget.from), + ), child: Text(l10n.authLoginForgotPassword), ), TextButton( key: const Key('login_register_link'), - onPressed: busy ? null : () => context.go(AppRoutes.register), + onPressed: busy + ? null + : () => context.go( + authLocation(AppRoutes.register, from: widget.from), + ), child: Text(l10n.authLoginCreateAccount), ), ], diff --git a/app/lib/features/auth/presentation/providers/auth_redirect.dart b/app/lib/features/auth/presentation/providers/auth_redirect.dart index e79d840..9822131 100644 --- a/app/lib/features/auth/presentation/providers/auth_redirect.dart +++ b/app/lib/features/auth/presentation/providers/auth_redirect.dart @@ -96,6 +96,19 @@ String? safeFromPath(String? from) { return from; } +/// Pfad [path] mit dem geprüften `from`-Ziel als Query. Ein unsicheres oder +/// fehlendes [from] ergibt den Pfad ohne Query (Weitergabe zwischen den +/// Auth-Seiten, Prüfung wie beim Login). +String authLocation(String path, {String? from}) { + final safe = safeFromPath(from); + return safe == null + ? path + : Uri( + path: path, + queryParameters: {AppRoutes.fromParam: safe}, + ).toString(); +} + /// [RedirectSource] mit Anmeldestatus für den Router. class AuthRedirectSource implements RedirectSource { AuthRedirectSource({required this.readStatus, this.privatePaths = const []}); diff --git a/app/lib/features/auth/presentation/register/register_screen.dart b/app/lib/features/auth/presentation/register/register_screen.dart index 032a0b9..a9584bc 100644 --- a/app/lib/features/auth/presentation/register/register_screen.dart +++ b/app/lib/features/auth/presentation/register/register_screen.dart @@ -18,6 +18,7 @@ import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; import 'package:shuffle/features/auth/domain/validation/email_format.dart'; import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; import 'package:shuffle/features/auth/presentation/register/register_state.dart'; import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; @@ -26,7 +27,10 @@ import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dar /// Kriterium 1). Only shows state and forwards input to the /// [RegisterController]. class RegisterScreen extends ConsumerStatefulWidget { - const RegisterScreen({super.key}); + const RegisterScreen({this.from, super.key}); + + /// Unchecked `from` query parameter, carried back to the sign-in page. + final String? from; @override ConsumerState createState() => _RegisterScreenState(); @@ -240,7 +244,11 @@ class _RegisterScreenState extends ConsumerState { const SizedBox(height: AppSpacing.sm), TextButton( key: const Key('register_login_link'), - onPressed: busy ? null : () => context.go(AppRoutes.login), + onPressed: busy + ? null + : () => context.go( + authLocation(AppRoutes.login, from: widget.from), + ), child: Text(l10n.authRegisterHaveAccount), ), ], diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart b/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart new file mode 100644 index 0000000..b6b8372 --- /dev/null +++ b/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/reset_password/reset_password_state.dart'; + +part 'reset_password_controller.g.dart'; + +final _log = AppLogger('ResetPasswordController'); + +/// `true` as soon as the new password is set during a recovery. Derived from +/// the auth status, so the success view survives a rebuild of the screen. +@riverpod +bool passwordRecoveryCompleted(Ref ref) { + final status = + ref.watch(authStatusProvider).value ?? + ref.watch(authRepositoryProvider).currentStatus; + return switch (status) { + PasswordRecovery(:final passwordChanged) => passwordChanged, + _ => false, + }; +} + +/// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9). +/// +/// The page is only reachable in the recovery guard (router redirect). +@riverpod +class ResetPasswordController extends _$ResetPasswordController { + Future? _commonPasswordsLoad; + + @override + ResetPasswordState build() => const ResetPasswordState(); + + void passwordChanged(String value) { + // The password is checked while typing (Kriterium 3). + state = state.copyWith( + password: value, + passwordVisible: true, + serverPasswordReasons: null, + serverSamePassword: false, + ); + unawaited(loadCommonPasswords()); + } + + /// Field left: show its error. + void passwordBlurred() { + state = state.copyWith(passwordVisible: true); + } + + /// Starts loading the list of common passwords (lazy, once). + Future loadCommonPasswords() => _commonPasswordsLoad ??= _load(); + + Future _load() async { + try { + final list = await ref.read(commonPasswordListProvider.future); + if (!ref.mounted) { + return; + } + state = state.copyWith(commonPasswords: list); + } on Object catch (error) { + // Fallback: only the length rule runs in the client; the server + // enforces the minimum length as well. + _log.warning('common password list unavailable: ${error.runtimeType}'); + } + } + + /// Validates and sets the new password. Returns `true` on success (the + /// auth status then moves on to "password changed"). + Future submit() async { + if (state.isSubmitting) { + return false; + } + // Set before the first await so that a second trigger is ignored. + state = state.copyWith(status: ResetPasswordStatus.submitting); + await loadCommonPasswords(); + if (!ref.mounted) { + return false; + } + state = state.copyWith( + passwordVisible: true, + bannerFailure: null, + serverPasswordReasons: null, + serverSamePassword: false, + ); + if (state.hasLocalError) { + state = state.copyWith( + status: ResetPasswordStatus.editing, + focusRequest: state.focusRequest + 1, + ); + return false; + } + + final result = await ref + .read(authRepositoryProvider) + .setNewPassword(state.password); + if (!ref.mounted) { + return false; + } + state = state.copyWith(status: ResetPasswordStatus.editing); + final failure = result.errorOrNull; + if (failure == null) { + return true; + } + switch (failure) { + case WeakPassword(:final reasons): + state = state.copyWith( + serverPasswordReasons: reasons, + focusRequest: state.focusRequest + 1, + ); + case SamePassword(): + state = state.copyWith( + serverSamePassword: true, + focusRequest: state.focusRequest + 1, + ); + default: + state = state.copyWith(bannerFailure: failure); + } + return false; + } + + /// "Continue" after the success message: ends the recovery guard (the + /// router then leaves `/reset-password`). + void finish() => ref.read(authRepositoryProvider).finishPasswordRecovery(); + + /// "Cancel": signs out, the recovery session is not kept. + Future cancel() async { + if (state.isSubmitting) { + return; + } + await ref.read(authRepositoryProvider).signOut(); + } +} diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart b/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart new file mode 100644 index 0000000..88bcfaa --- /dev/null +++ b/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart @@ -0,0 +1,126 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reset_password_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// `true` as soon as the new password is set during a recovery. Derived from +/// the auth status, so the success view survives a rebuild of the screen. + +@ProviderFor(passwordRecoveryCompleted) +final passwordRecoveryCompletedProvider = PasswordRecoveryCompletedProvider._(); + +/// `true` as soon as the new password is set during a recovery. Derived from +/// the auth status, so the success view survives a rebuild of the screen. + +final class PasswordRecoveryCompletedProvider + extends $FunctionalProvider + with $Provider { + /// `true` as soon as the new password is set during a recovery. Derived from + /// the auth status, so the success view survives a rebuild of the screen. + PasswordRecoveryCompletedProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'passwordRecoveryCompletedProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$passwordRecoveryCompletedHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + bool create(Ref ref) { + return passwordRecoveryCompleted(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$passwordRecoveryCompletedHash() => + r'b81db3ec299c7f3a3b567fe69303b64e8b45c147'; + +/// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9). +/// +/// The page is only reachable in the recovery guard (router redirect). + +@ProviderFor(ResetPasswordController) +final resetPasswordControllerProvider = ResetPasswordControllerProvider._(); + +/// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9). +/// +/// The page is only reachable in the recovery guard (router redirect). +final class ResetPasswordControllerProvider + extends $NotifierProvider { + /// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9). + /// + /// The page is only reachable in the recovery guard (router redirect). + ResetPasswordControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'resetPasswordControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$resetPasswordControllerHash(); + + @$internal + @override + ResetPasswordController create() => ResetPasswordController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(ResetPasswordState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$resetPasswordControllerHash() => + r'ab273bab468ed730052be4877484943ad49f6e15'; + +/// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9). +/// +/// The page is only reachable in the recovery guard (router redirect). + +abstract class _$ResetPasswordController extends $Notifier { + ResetPasswordState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + ResetPasswordState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart b/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart new file mode 100644 index 0000000..625e448 --- /dev/null +++ b/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/app_banner.dart'; +import 'package:shuffle/core/widgets/app_form_scaffold.dart'; +import 'package:shuffle/core/widgets/app_password_field.dart'; +import 'package:shuffle/core/widgets/app_primary_button.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/reset_password/reset_password_controller.dart'; +import 'package:shuffle/features/auth/presentation/reset_password/reset_password_state.dart'; +import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; + +/// Sets the new password in the recovery guard (`/reset-password`, +/// KTO-01 Kriterium 3, 8 und 9). Only shows state and forwards input to the +/// [ResetPasswordController]. +/// +/// After the change the page stays and shows the success message (the +/// recovery guard holds the router here) until the user continues. +class ResetPasswordScreen extends ConsumerStatefulWidget { + const ResetPasswordScreen({super.key}); + + @override + ConsumerState createState() => + _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends ConsumerState { + final _password = TextEditingController(); + final _passwordFocus = FocusNode(); + + /// The user is typing in the password field (see `liveError` below). + bool _typing = false; + + @override + void initState() { + super.initState(); + _passwordFocus.addListener(() { + if (_passwordFocus.hasFocus) { + unawaited( + ref + .read(resetPasswordControllerProvider.notifier) + .loadCommonPasswords(), + ); + } else { + ref.read(resetPasswordControllerProvider.notifier).passwordBlurred(); + if (_typing && mounted) { + setState(() => _typing = false); + } + } + }); + } + + @override + void dispose() { + _password.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + final succeeded = await ref + .read(resetPasswordControllerProvider.notifier) + .submit(); + if (succeeded) { + TextInput.finishAutofillContext(); + } + } + + void _continue() { + ref.read(resetPasswordControllerProvider.notifier).finish(); + if (mounted) { + context.go(AppRoutes.home); + } + } + + @override + Widget build(BuildContext context) { + ref.listen(resetPasswordControllerProvider, (previous, next) { + if (previous?.focusRequest != next.focusRequest) { + _passwordFocus.requestFocus(); + } + }); + final state = ref.watch(resetPasswordControllerProvider); + final completed = ref.watch(passwordRecoveryCompletedProvider); + final l10n = context.l10n; + + return AppFormScaffold( + key: const Key('reset_password_screen'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authResetTitle), + const SizedBox(height: AppSpacing.lg), + if (completed) ...[ + AppBanner( + key: const Key('reset_password_success_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: l10n.authPasswordChanged, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('reset_password_continue'), + label: l10n.authConfirmContinue, + onPressed: _continue, + ), + ] else + ..._form(context, state), + ], + ), + ); + } + + List _form(BuildContext context, ResetPasswordState state) { + final controller = ref.read(resetPasswordControllerProvider.notifier); + final l10n = context.l10n; + final busy = state.isSubmitting; + final failure = state.bannerFailure; + return [ + if (failure != null) ...[ + AppBanner( + key: const Key('reset_password_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + actionLabel: failure.isRetryable ? l10n.authActionRetry : null, + onAction: failure.isRetryable ? () => unawaited(_submit()) : null, + actionEnabled: !busy, + ), + const SizedBox(height: AppSpacing.lg), + ], + AutofillGroup( + child: AppPasswordField( + key: const Key('reset_password_password'), + label: l10n.authFieldNewPasswordLabel, + showPasswordLabel: l10n.authPasswordShow, + hidePasswordLabel: l10n.authPasswordHide, + controller: _password, + focusNode: _passwordFocus, + isNewPassword: true, + textInputAction: TextInputAction.done, + enabled: !busy, + ruleHint: l10n.authPasswordRule, + errorText: _passwordError(l10n, state), + // "n missing" changes with every keystroke: announce it only when + // the field is left. The visible hint stays. + liveError: !_typing || !_isLengthHintOnly(state), + onChanged: (value) { + if (!_typing) { + setState(() => _typing = true); + } + controller.passwordChanged(value); + }, + onSubmitted: (_) { + if (!busy) { + unawaited(_submit()); + } + }, + ), + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('reset_password_submit'), + label: l10n.authResetSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: busy, + onPressed: _submit, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('reset_password_cancel'), + onPressed: busy ? null : () => unawaited(controller.cancel()), + child: Text(l10n.authResetCancel), + ), + ]; + } + + bool _isLengthHintOnly(ResetPasswordState state) => + state.serverPasswordReasons == null && + !state.serverSamePassword && + !state.shownPasswordRequired && + state.shownPasswordError is PasswordTooShort; + + String? _passwordError(AppLocalizations l10n, ResetPasswordState state) { + if (state.serverPasswordReasons case final reasons?) { + return weakPasswordMessage(l10n, reasons); + } + if (state.serverSamePassword) { + return l10n.authErrorSamePassword; + } + if (state.shownPasswordRequired) { + return l10n.authPasswordRequired; + } + return switch (state.shownPasswordError) { + PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing), + PasswordCommon() => l10n.authPasswordCommon, + null => null, + }; + } +} diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_state.dart b/app/lib/features/auth/presentation/reset_password/reset_password_state.dart new file mode 100644 index 0000000..5f23ccc --- /dev/null +++ b/app/lib/features/auth/presentation/reset_password/reset_password_state.dart @@ -0,0 +1,55 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; + +part 'reset_password_state.freezed.dart'; + +enum ResetPasswordStatus { editing, submitting } + +/// Value, error visibility and submit state of the "new password" form. +@freezed +abstract class ResetPasswordState with _$ResetPasswordState { + const factory ResetPasswordState({ + @Default('') String password, + + /// The password error is shown (typing, field left or submit attempted). + @Default(false) bool passwordVisible, + @Default(ResetPasswordStatus.editing) ResetPasswordStatus status, + + /// Loaded lazily with the first use of the password field. + CommonPasswordList? commonPasswords, + + /// `weak_password` reasons reported by the server, shown at the field. + List? serverPasswordReasons, + + /// `same_password`: the new password equals the old one. + @Default(false) bool serverSamePassword, + + /// General failure shown as banner. + AuthFailure? bannerFailure, + + /// Incremented when the password field should receive focus. + @Default(0) int focusRequest, + }) = _ResetPasswordState; + + const ResetPasswordState._(); + + /// No credentials in logs or error reports. + @override + String toString() => + 'ResetPasswordState(password: , status: $status, ' + 'bannerFailure: $bannerFailure)'; + + bool get isSubmitting => status == ResetPasswordStatus.submitting; + + PasswordError? get passwordError => + PasswordPolicy.validate(password, common: commonPasswords); + + bool get hasLocalError => password.isEmpty || passwordError != null; + + bool get shownPasswordRequired => passwordVisible && password.isEmpty; + + PasswordError? get shownPasswordError => + passwordVisible ? passwordError : null; +} diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_state.freezed.dart b/app/lib/features/auth/presentation/reset_password/reset_password_state.freezed.dart new file mode 100644 index 0000000..2e46346 --- /dev/null +++ b/app/lib/features/auth/presentation/reset_password/reset_password_state.freezed.dart @@ -0,0 +1,336 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'reset_password_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ResetPasswordState { + + String get password;/// The password error is shown (typing, field left or submit attempted). + bool get passwordVisible; ResetPasswordStatus get status;/// Loaded lazily with the first use of the password field. + CommonPasswordList? get commonPasswords;/// `weak_password` reasons reported by the server, shown at the field. + List? get serverPasswordReasons;/// `same_password`: the new password equals the old one. + bool get serverSamePassword;/// General failure shown as banner. + AuthFailure? get bannerFailure;/// Incremented when the password field should receive focus. + int get focusRequest; +/// Create a copy of ResetPasswordState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ResetPasswordStateCopyWith get copyWith => _$ResetPasswordStateCopyWithImpl(this as ResetPasswordState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as ResetPasswordState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is ResetPasswordState&&(identical(other.password, _this.password) || other.password == _this.password)&&(identical(other.passwordVisible, _this.passwordVisible) || other.passwordVisible == _this.passwordVisible)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.commonPasswords, _this.commonPasswords) || other.commonPasswords == _this.commonPasswords)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _this.serverPasswordReasons)&&(identical(other.serverSamePassword, _this.serverSamePassword) || other.serverSamePassword == _this.serverSamePassword)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&(identical(other.focusRequest, _this.focusRequest) || other.focusRequest == _this.focusRequest)); +} + + +@override +int get hashCode { + final _this = this as ResetPasswordState; + return Object.hash(runtimeType,_this.password,_this.passwordVisible,_this.status,_this.commonPasswords,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverSamePassword,_this.bannerFailure,_this.focusRequest); +} + + + +} + +/// @nodoc +abstract mixin class $ResetPasswordStateCopyWith<$Res> { + factory $ResetPasswordStateCopyWith(ResetPasswordState value, $Res Function(ResetPasswordState) _then) = _$ResetPasswordStateCopyWithImpl; +@useResult +$Res call({ + String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List? serverPasswordReasons, bool serverSamePassword, AuthFailure? bannerFailure, int focusRequest +}); + + +$AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class _$ResetPasswordStateCopyWithImpl<$Res> + implements $ResetPasswordStateCopyWith<$Res> { + _$ResetPasswordStateCopyWithImpl(this._self, this._then); + + final ResetPasswordState _self; + final $Res Function(ResetPasswordState) _then; + +/// Create a copy of ResetPasswordState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? password = null,Object? passwordVisible = null,Object? status = null,Object? commonPasswords = freezed,Object? serverPasswordReasons = freezed,Object? serverSamePassword = null,Object? bannerFailure = freezed,Object? focusRequest = null,}) { + return _then(ResetPasswordState( +password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,passwordVisible: null == passwordVisible ? _self.passwordVisible : passwordVisible // ignore: cast_nullable_to_non_nullable +as bool,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as ResetPasswordStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable +as CommonPasswordList?,serverPasswordReasons: freezed == serverPasswordReasons ? _self.serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable +as List?,serverSamePassword: null == serverSamePassword ? _self.serverSamePassword : serverSamePassword // ignore: cast_nullable_to_non_nullable +as bool,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of ResetPasswordState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ResetPasswordState]. +extension ResetPasswordStatePatterns on ResetPasswordState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ResetPasswordState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ResetPasswordState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ResetPasswordState value) $default,){ +final _that = this; +switch (_that) { +case _ResetPasswordState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ResetPasswordState value)? $default,){ +final _that = this; +switch (_that) { +case _ResetPasswordState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List? serverPasswordReasons, bool serverSamePassword, AuthFailure? bannerFailure, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ResetPasswordState() when $default != null: +return $default(_that.password,_that.passwordVisible,_that.status,_that.commonPasswords,_that.serverPasswordReasons,_that.serverSamePassword,_that.bannerFailure,_that.focusRequest);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List? serverPasswordReasons, bool serverSamePassword, AuthFailure? bannerFailure, int focusRequest) $default,) {final _that = this; +switch (_that) { +case _ResetPasswordState(): +return $default(_that.password,_that.passwordVisible,_that.status,_that.commonPasswords,_that.serverPasswordReasons,_that.serverSamePassword,_that.bannerFailure,_that.focusRequest);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List? serverPasswordReasons, bool serverSamePassword, AuthFailure? bannerFailure, int focusRequest)? $default,) {final _that = this; +switch (_that) { +case _ResetPasswordState() when $default != null: +return $default(_that.password,_that.passwordVisible,_that.status,_that.commonPasswords,_that.serverPasswordReasons,_that.serverSamePassword,_that.bannerFailure,_that.focusRequest);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ResetPasswordState extends ResetPasswordState { + const _ResetPasswordState({this.password = '', this.passwordVisible = false, this.status = ResetPasswordStatus.editing, this.commonPasswords, List? serverPasswordReasons, this.serverSamePassword = false, this.bannerFailure, this.focusRequest = 0}): _serverPasswordReasons = serverPasswordReasons,super._(); + + +@override@JsonKey() final String password; +/// The password error is shown (typing, field left or submit attempted). +@override@JsonKey() final bool passwordVisible; +@override@JsonKey() final ResetPasswordStatus status; +/// Loaded lazily with the first use of the password field. +@override final CommonPasswordList? commonPasswords; +/// `weak_password` reasons reported by the server, shown at the field. + final List? _serverPasswordReasons; +/// `weak_password` reasons reported by the server, shown at the field. +@override List? get serverPasswordReasons { + final value = _serverPasswordReasons; + if (value == null) return null; + if (_serverPasswordReasons is EqualUnmodifiableListView) return _serverPasswordReasons; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); +} + +/// `same_password`: the new password equals the old one. +@override@JsonKey() final bool serverSamePassword; +/// General failure shown as banner. +@override final AuthFailure? bannerFailure; +/// Incremented when the password field should receive focus. +@override@JsonKey() final int focusRequest; + +/// Create a copy of ResetPasswordState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ResetPasswordStateCopyWith<_ResetPasswordState> get copyWith => __$ResetPasswordStateCopyWithImpl<_ResetPasswordState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ResetPasswordState&&(identical(other.password, password) || other.password == password)&&(identical(other.passwordVisible, passwordVisible) || other.passwordVisible == passwordVisible)&&(identical(other.status, status) || other.status == status)&&(identical(other.commonPasswords, commonPasswords) || other.commonPasswords == commonPasswords)&&const DeepCollectionEquality().equals(other.serverPasswordReasons, _serverPasswordReasons)&&(identical(other.serverSamePassword, serverSamePassword) || other.serverSamePassword == serverSamePassword)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,password,passwordVisible,status,commonPasswords,const DeepCollectionEquality().hash(_serverPasswordReasons),serverSamePassword,bannerFailure,focusRequest); +} + + + +} + +/// @nodoc +abstract mixin class _$ResetPasswordStateCopyWith<$Res> implements $ResetPasswordStateCopyWith<$Res> { + factory _$ResetPasswordStateCopyWith(_ResetPasswordState value, $Res Function(_ResetPasswordState) _then) = __$ResetPasswordStateCopyWithImpl; +@override @useResult +$Res call({ + String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List? serverPasswordReasons, bool serverSamePassword, AuthFailure? bannerFailure, int focusRequest +}); + + +@override $AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class __$ResetPasswordStateCopyWithImpl<$Res> + implements _$ResetPasswordStateCopyWith<$Res> { + __$ResetPasswordStateCopyWithImpl(this._self, this._then); + + final _ResetPasswordState _self; + final $Res Function(_ResetPasswordState) _then; + +/// Create a copy of ResetPasswordState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? password = null,Object? passwordVisible = null,Object? status = null,Object? commonPasswords = freezed,Object? serverPasswordReasons = freezed,Object? serverSamePassword = null,Object? bannerFailure = freezed,Object? focusRequest = null,}) { + return _then(_ResetPasswordState( +password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,passwordVisible: null == passwordVisible ? _self.passwordVisible : passwordVisible // ignore: cast_nullable_to_non_nullable +as bool,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as ResetPasswordStatus,commonPasswords: freezed == commonPasswords ? _self.commonPasswords : commonPasswords // ignore: cast_nullable_to_non_nullable +as CommonPasswordList?,serverPasswordReasons: freezed == serverPasswordReasons ? _self._serverPasswordReasons : serverPasswordReasons // ignore: cast_nullable_to_non_nullable +as List?,serverSamePassword: null == serverSamePassword ? _self.serverSamePassword : serverSamePassword // ignore: cast_nullable_to_non_nullable +as bool,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of ResetPasswordState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 7efc12a..1091ebc 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -67,5 +67,15 @@ "authConfirmContinue": "Weiter", "authRecoveryTitle": "Neues Passwort festlegen", "authRecoveryBody": "Ein Klick genügt, um ein neues Passwort festzulegen.", - "authRecoverySubmit": "Weiter zum neuen Passwort" + "authRecoverySubmit": "Weiter zum neuen Passwort", + "authForgotTitle": "Passwort zurücksetzen", + "authForgotBody": "Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen.", + "authForgotSubmit": "Link senden", + "authCheckEmailBodyReset": "Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen geschickt, falls zu dieser Adresse ein Konto besteht.", + "authCheckEmailDifferentAddress": "Andere E-Mail-Adresse eingeben", + "authFieldNewPasswordLabel": "Neues Passwort", + "authResetTitle": "Neues Passwort festlegen", + "authResetSubmit": "Passwort speichern", + "authResetCancel": "Abbrechen", + "authPasswordChanged": "Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 5893734..cc4c8e4 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -113,5 +113,15 @@ "authConfirmContinue": "Continue", "authRecoveryTitle": "Set a new password", "authRecoveryBody": "One click is enough to set a new password.", - "authRecoverySubmit": "Continue to new password" + "authRecoverySubmit": "Continue to new password", + "authForgotTitle": "Reset password", + "authForgotBody": "Enter the email address of your account. We will send you a reset link.", + "authForgotSubmit": "Send link", + "authCheckEmailBodyReset": "We sent you an email with a reset link if an account exists for this address.", + "authCheckEmailDifferentAddress": "Enter a different email address", + "authFieldNewPasswordLabel": "New password", + "authResetTitle": "Set a new password", + "authResetSubmit": "Save password", + "authResetCancel": "Cancel", + "authPasswordChanged": "Password changed. On other devices you will be signed out after about an hour at the latest." } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index b23d9f3..2e9eda5 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -505,6 +505,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Continue to new password'** String get authRecoverySubmit; + + /// No description provided for @authForgotTitle. + /// + /// In en, this message translates to: + /// **'Reset password'** + String get authForgotTitle; + + /// No description provided for @authForgotBody. + /// + /// In en, this message translates to: + /// **'Enter the email address of your account. We will send you a reset link.'** + String get authForgotBody; + + /// No description provided for @authForgotSubmit. + /// + /// In en, this message translates to: + /// **'Send link'** + String get authForgotSubmit; + + /// No description provided for @authCheckEmailBodyReset. + /// + /// In en, this message translates to: + /// **'We sent you an email with a reset link if an account exists for this address.'** + String get authCheckEmailBodyReset; + + /// No description provided for @authCheckEmailDifferentAddress. + /// + /// In en, this message translates to: + /// **'Enter a different email address'** + String get authCheckEmailDifferentAddress; + + /// No description provided for @authFieldNewPasswordLabel. + /// + /// In en, this message translates to: + /// **'New password'** + String get authFieldNewPasswordLabel; + + /// No description provided for @authResetTitle. + /// + /// In en, this message translates to: + /// **'Set a new password'** + String get authResetTitle; + + /// No description provided for @authResetSubmit. + /// + /// In en, this message translates to: + /// **'Save password'** + String get authResetSubmit; + + /// No description provided for @authResetCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get authResetCancel; + + /// No description provided for @authPasswordChanged. + /// + /// In en, this message translates to: + /// **'Password changed. On other devices you will be signed out after about an hour at the latest.'** + String get authPasswordChanged; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index b2b7380..f8071c1 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -249,4 +249,37 @@ class AppLocalizationsDe extends AppLocalizations { @override String get authRecoverySubmit => 'Weiter zum neuen Passwort'; + + @override + String get authForgotTitle => 'Passwort zurücksetzen'; + + @override + String get authForgotBody => + 'Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen.'; + + @override + String get authForgotSubmit => 'Link senden'; + + @override + String get authCheckEmailBodyReset => + 'Wir haben dir eine E-Mail mit einem Link zum Zurücksetzen geschickt, falls zu dieser Adresse ein Konto besteht.'; + + @override + String get authCheckEmailDifferentAddress => 'Andere E-Mail-Adresse eingeben'; + + @override + String get authFieldNewPasswordLabel => 'Neues Passwort'; + + @override + String get authResetTitle => 'Neues Passwort festlegen'; + + @override + String get authResetSubmit => 'Passwort speichern'; + + @override + String get authResetCancel => 'Abbrechen'; + + @override + String get authPasswordChanged => + 'Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index b86fff5..f272081 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -245,4 +245,38 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authRecoverySubmit => 'Continue to new password'; + + @override + String get authForgotTitle => 'Reset password'; + + @override + String get authForgotBody => + 'Enter the email address of your account. We will send you a reset link.'; + + @override + String get authForgotSubmit => 'Send link'; + + @override + String get authCheckEmailBodyReset => + 'We sent you an email with a reset link if an account exists for this address.'; + + @override + String get authCheckEmailDifferentAddress => + 'Enter a different email address'; + + @override + String get authFieldNewPasswordLabel => 'New password'; + + @override + String get authResetTitle => 'Set a new password'; + + @override + String get authResetSubmit => 'Save password'; + + @override + String get authResetCancel => 'Cancel'; + + @override + String get authPasswordChanged => + 'Password changed. On other devices you will be signed out after about an hour at the latest.'; } diff --git a/app/test/features/auth/data/auth_status_tracker_test.dart b/app/test/features/auth/data/auth_status_tracker_test.dart index 5a3304a..4da5840 100644 --- a/app/test/features/auth/data/auth_status_tracker_test.dart +++ b/app/test/features/auth/data/auth_status_tracker_test.dart @@ -82,9 +82,54 @@ void main() { expect(tracker.statusFor(testSession()), isA()); }); - test('userUpdated (password changed) ends the recovery', () { + test( + 'userUpdated (password changed) keeps the guard, marks the change', + () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()); + final status = tracker.handle( + AuthChangeEvent.userUpdated, + testSession(), + ); + expect(status, isA()); + expect((status as PasswordRecovery).passwordChanged, isTrue); + // Still held after a token refresh. + expect( + tracker.handle(AuthChangeEvent.tokenRefreshed, testSession()), + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + ); + }, + ); + + test('finishRecovery ends the guard', () { final tracker = AuthStatusTracker() - ..handle(AuthChangeEvent.passwordRecovery, testSession()); + ..handle(AuthChangeEvent.passwordRecovery, testSession()) + ..handle(AuthChangeEvent.userUpdated, testSession()); + expect(tracker.finishRecovery(testSession()), isA()); + expect(tracker.statusFor(testSession()), isA()); + }); + + test('a new recovery resets the passwordChanged mark', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()) + ..handle(AuthChangeEvent.userUpdated, testSession()); + expect( + tracker.handle(AuthChangeEvent.passwordRecovery, testSession()), + isA().having( + (s) => s.passwordChanged, + 'changed', + false, + ), + ); + }); + + test('userUpdated without a recovery stays signedIn', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.signedIn, testSession()); expect( tracker.handle(AuthChangeEvent.userUpdated, testSession()), isA(), diff --git a/app/test/features/auth/data/supabase_auth_repository_test.dart b/app/test/features/auth/data/supabase_auth_repository_test.dart index 994a68e..2496d6b 100644 --- a/app/test/features/auth/data/supabase_auth_repository_test.dart +++ b/app/test/features/auth/data/supabase_auth_repository_test.dart @@ -329,15 +329,51 @@ void main() { events.add(const AuthState(AuthChangeEvent.signedOut, null)); await Future.delayed(Duration.zero); - expect(emitted.map((s) => s.runtimeType.toString()), [ - 'SignedOut', - 'SignedIn', - 'PasswordRecovery', - 'SignedIn', - 'SignedOut', + expect(emitted, [ + const AuthStatus.signedOut(), + isA(), + isA().having( + (s) => s.passwordChanged, + 'changed', + false, + ), + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + const AuthStatus.signedOut(), ]); }); + test('finishPasswordRecovery ends the guard after the password change', () { + currentSession = testSession(); + events + ..add(AuthState(AuthChangeEvent.passwordRecovery, testSession())) + ..add(AuthState(AuthChangeEvent.userUpdated, testSession())); + expect( + repository.currentStatus, + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + ); + + repository.finishPasswordRecovery(); + + expect(repository.currentStatus, isA()); + }); + + test('finishPasswordRecovery without a recovery changes nothing', () { + currentSession = testSession(); + events.add(AuthState(AuthChangeEvent.signedIn, testSession())); + + repository.finishPasswordRecovery(); + + expect(repository.currentStatus, isA()); + }); + test('identical statuses (token refresh) are not re-emitted', () async { final emitted = []; final sub = repository.watchStatus().listen(emitted.add); diff --git a/app/test/features/auth/presentation/auth_redirect_test.dart b/app/test/features/auth/presentation/auth_redirect_test.dart index f248b5a..55d4e36 100644 --- a/app/test/features/auth/presentation/auth_redirect_test.dart +++ b/app/test/features/auth/presentation/auth_redirect_test.dart @@ -12,6 +12,10 @@ const _user = AuthUser( const _signedOut = AuthStatus.signedOut(); const _signedIn = AuthStatus.signedIn(_user); const _recovery = AuthStatus.passwordRecovery(_user); +const _recoveryChanged = AuthStatus.passwordRecovery( + _user, + passwordChanged: true, +); String? _redirect( AuthStatus status, @@ -41,6 +45,13 @@ void main() { test('/reset-password stays', () { expect(_redirect(_recovery, '/reset-password'), isNull); }); + + test('after the password change the guard still holds the page', () { + // Otherwise the success message (Kriterium 9) could never be shown. + expect(_redirect(_recoveryChanged, '/reset-password'), isNull); + expect(_redirect(_recoveryChanged, '/'), AppRoutes.resetPassword); + expect(_redirect(_recoveryChanged, '/login'), AppRoutes.resetPassword); + }); }); group('KTO-01 Kriterium 5: signedIn', () { @@ -133,6 +144,26 @@ void main() { }); }); + group('authLocation (from forwarding)', () { + test('adds a safe from as query', () { + expect( + authLocation('/register', from: '/private?a=1'), + '/register?from=%2Fprivate%3Fa%3D1', + ); + }); + + test('drops missing and unsafe values', () { + expect(authLocation('/register'), '/register'); + for (final from in ['//evil.example', 'https://evil.example', '/login']) { + expect( + authLocation('/register', from: from), + '/register', + reason: from, + ); + } + }); + }); + group('safeFromPath', () { test('rejects auth pages with a trailing slash', () { expect(safeFromPath('/login/'), isNull); diff --git a/app/test/features/auth/presentation/auth_router_test.dart b/app/test/features/auth/presentation/auth_router_test.dart index fce5c1d..df3da27 100644 --- a/app/test/features/auth/presentation/auth_router_test.dart +++ b/app/test/features/auth/presentation/auth_router_test.dart @@ -16,6 +16,7 @@ import '../../../helpers/fakes.dart'; import '../../../helpers/pump_app.dart'; import '../../../helpers/router_helpers.dart'; import '../data/auth_test_support.dart'; +import 'auth_ui_support.dart' show commonTestPasswords; void main() { late SupabaseMocks mocks; @@ -46,6 +47,10 @@ void main() { supabase: mocks.client, overrides: [ ...authOverrides(), + // No asset loading in router tests. + commonPasswordListProvider.overrideWith( + (ref) => Future.value(commonTestPasswords), + ), privateRoutePathsProvider.overrideWithValue(const ['/private']), signedOutHandlersProvider.overrideWithValue([ (ref) => signedOutCalls++, @@ -115,24 +120,22 @@ void main() { expect(currentPath(router), '/'); }); - testWidgets( - 'routes of spec 5.3 exist (01b: login real, rest placeholders)', - (tester) async { - final (container: _, :router) = await start(tester); + testWidgets('routes of spec 5.3 exist', (tester) async { + final (container: _, :router) = await start(tester); - for (final (path, key) in [ - ('/login', 'login_screen'), - ('/register', 'register_screen'), - ('/register/check-email', 'check_email_screen'), - ('/forgot-password', 'auth_placeholder_forgot_password'), - ('/auth/confirm?token_hash=x&type=email', 'email_link_screen'), - ]) { - router.go(path); - await tester.pumpAndSettle(); - expect(find.byKey(Key(key)), findsOneWidget, reason: path); - } - }, - ); + for (final (path, key) in [ + ('/login', 'login_screen'), + ('/register', 'register_screen'), + ('/register/check-email', 'check_email_screen'), + ('/forgot-password', 'forgot_password_screen'), + ('/forgot-password/check-email', 'check_email_screen'), + ('/auth/confirm?token_hash=x&type=email', 'email_link_screen'), + ]) { + router.go(path); + await tester.pumpAndSettle(); + expect(find.byKey(Key(key)), findsOneWidget, reason: path); + } + }); }); group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () { @@ -144,10 +147,7 @@ void main() { emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); - expect( - find.byKey(const Key('auth_placeholder_reset_password')), - findsOneWidget, - ); + expect(find.byKey(const Key('reset_password_screen')), findsOneWidget); router.go('/'); await tester.pumpAndSettle(); @@ -157,9 +157,19 @@ void main() { await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); + // The password change alone does not release the guard: the success + // message must stay visible (KTO-01 Kriterium 9). emit(AuthChangeEvent.userUpdated, withSession: testSession()); await tester.pumpAndSettle(); - expect(currentPath(router), '/'); + expect(currentPath(router), '/reset-password'); + expect( + find.byKey(const Key('reset_password_success_banner')), + findsOneWidget, + ); + + router.go('/'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); }); testWidgets('aborting the recovery (signOut) leaves /reset-password', ( @@ -211,6 +221,117 @@ void main() { }); }); + group('KTO-01 Kriterium 8, 9: password reset flow through the router', () { + void stubGoTrue() { + registerFallbackValue(OtpType.recovery); + registerFallbackValue(UserAttributes()); + when( + () => mocks.auth.verifyOTP( + type: any(named: 'type'), + tokenHash: any(named: 'tokenHash'), + ), + ).thenAnswer((_) async { + // gotrue emits passwordRecovery for a redeemed recovery link. + emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); + return AuthResponse(session: testSession()); + }); + when(() => mocks.auth.updateUser(any())).thenAnswer((_) async { + // gotrue emits userUpdated before updateUser returns. + emit(AuthChangeEvent.userUpdated, withSession: testSession()); + return UserResponse.fromJson(testUser().toJson()); + }); + when(() => mocks.auth.signOut()).thenAnswer((_) async { + emit(AuthChangeEvent.signedOut); + }); + } + + Future redeemRecoveryLink( + WidgetTester tester, + GoRouter router, + ) async { + router.go('/auth/confirm?token_hash=abc&type=recovery'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/auth/confirm'); + await tester.tap(find.byKey(const Key('email_link_submit'))); + await tester.pumpAndSettle(); + } + + Future savePassword(WidgetTester tester, String password) async { + await tester.enterText( + find.descendant( + of: find.byKey(const Key('reset_password_password')), + matching: find.byType(TextField), + ), + password, + ); + await tester.pump(); + await tester.tap(find.byKey(const Key('reset_password_submit'))); + await tester.pumpAndSettle(); + } + + testWidgets('link -> new password -> message stays -> continue -> start', ( + tester, + ) async { + stubGoTrue(); + final (container: _, :router) = await start(tester); + + await redeemRecoveryLink(tester, router); + expect(currentPath(router), '/reset-password'); + expect(find.byKey(const Key('reset_password_screen')), findsOneWidget); + + // Navigating elsewhere is blocked while the guard holds. + router.go('/'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); + + await savePassword(tester, 'ein-langes-passwort'); + + verify(() => mocks.auth.updateUser(any())).called(1); + // The status moved to "changed", but the page and its message stay. + expect(currentPath(router), '/reset-password'); + expect( + find.byKey(const Key('reset_password_success_banner')), + findsOneWidget, + ); + router.go('/'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); + + await tester.tap(find.byKey(const Key('reset_password_continue'))); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/'); + // The session stays: the user is signed in with the new password. + router.go('/login'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/'); + verifyNever(() => mocks.auth.signOut()); + }); + + testWidgets('cancel signs out and leads to /login', (tester) async { + stubGoTrue(); + final (container: _, :router) = await start(tester); + await redeemRecoveryLink(tester, router); + expect(currentPath(router), '/reset-password'); + + await tester.tap(find.byKey(const Key('reset_password_cancel'))); + await tester.pumpAndSettle(); + + verify(() => mocks.auth.signOut()).called(1); + expect(currentPath(router), '/login'); + }); + + testWidgets('a signed-in user cannot open the reset page', (tester) async { + session = testSession(); + final (container: _, :router) = await start(tester); + + router.go('/reset-password'); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/'); + }); + }); + group('KTO-01 Kriterium 1: registrationInputFactory', () { testWidgets('fills terms_version from AppConfig and the app language', ( tester, diff --git a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart index 820ce0f..01411d8 100644 --- a/app/test/features/auth/presentation/check_email/check_email_screen_test.dart +++ b/app/test/features/auth/presentation/check_email/check_email_screen_test.dart @@ -168,6 +168,97 @@ void main() { }); }); + group('KTO-01 Kriterium 8: reset mode', () { + Future openReset( + WidgetTester tester, { + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + }) => pumpAuthRoute( + tester, + repository, + location: '/forgot-password/check-email', + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + ); + + testWidgets('shows the reset text, validity and the delay hint', ( + tester, + ) async { + await openReset(tester); + + expect(find.text(_de.authCheckEmailBodyReset), findsOneWidget); + expect(find.text(_de.authCheckEmailBody), findsNothing); + expect(find.text(_de.authCheckEmailValidity('1 Stunde')), findsOneWidget); + expect(find.text(_de.authCheckEmailDelayHint), findsOneWidget); + }); + + testWidgets('the mode comes from the path, so a reload keeps it', ( + tester, + ) async { + // No `extra` at all, exactly as after a web reload. + await openReset(tester); + + expect(find.byKey(const Key('check_email_resend')), findsNothing); + expect(find.byKey(const Key('check_email_email')), findsNothing); + }); + + testWidgets('has no resend; another address leads back to the form', ( + tester, + ) async { + final (:container, :router) = await openReset(tester); + + expect(find.text(_de.authCheckEmailResend), findsNothing); + await tester.tap(find.byKey(const Key('check_email_different_address'))); + await tester.pumpAndSettle(); + + verifyNever(() => repository.resendConfirmation(any())); + expect(currentPath(router), '/forgot-password'); + }); + + testWidgets('back to sign in leads to /login', (tester) async { + final (:container, :router) = await openReset(tester); + + await tester.ensureVisible( + find.byKey(const Key('check_email_back_to_login')), + ); + await tester.tap(find.byKey(const Key('check_email_back_to_login'))); + await tester.pumpAndSettle(); + + expect(currentPath(router), '/login'); + }); + + testWidgets('english texts', (tester) async { + await openReset(tester, locale: const Locale('en')); + + expect(find.text(_en.authCheckEmailBodyReset), findsOneWidget); + expect(find.text(_en.authCheckEmailDifferentAddress), findsOneWidget); + }); + + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await openReset(tester, themeMode: mode); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await openReset(tester, textScale: 2, size: const Size(320, 640)); + + expect(tester.takeException(), isNull); + }); + }); + testWidgets('back to sign in leads to /login', (tester) async { final (:container, :router) = await open(tester); diff --git a/app/test/features/auth/presentation/email_link/email_link_controller_test.dart b/app/test/features/auth/presentation/email_link/email_link_controller_test.dart index 0a03c95..4bf7dca 100644 --- a/app/test/features/auth/presentation/email_link/email_link_controller_test.dart +++ b/app/test/features/auth/presentation/email_link/email_link_controller_test.dart @@ -86,6 +86,32 @@ void main() { expect(stateOf('hash', 'signup').phase, EmailLinkPhase.success); }); + test( + 'KTO-01 Kriterium 8: a recovery link is redeemed as recovery', + () async { + when(() => repository.verifyEmailLink(any(), any())) + .thenAnswer((_) async => ok()); + final controller = start('hash', 'recovery'); + + await controller.confirm(); + + verify(() => repository.verifyEmailLink('hash', EmailLinkType.recovery)) + .called(1); + expect(stateOf('hash', 'recovery').phase, EmailLinkPhase.success); + }, + ); + + test('KTO-01 Kriterium 8: an expired recovery link is invalid', () async { + when(() => repository.verifyEmailLink(any(), any())).thenAnswer( + (_) async => failed(const AuthFailure.linkInvalidOrExpired()), + ); + final controller = start('hash', 'recovery'); + + await controller.confirm(); + + expect(stateOf('hash', 'recovery').phase, EmailLinkPhase.invalid); + }); + test('an expired or used link ends in the invalid state', () async { when(() => repository.verifyEmailLink(any(), any())).thenAnswer( (_) async => failed(const AuthFailure.linkInvalidOrExpired()), diff --git a/app/test/features/auth/presentation/email_link/email_link_screen_test.dart b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart index 7f4e627..c17e5d4 100644 --- a/app/test/features/auth/presentation/email_link/email_link_screen_test.dart +++ b/app/test/features/auth/presentation/email_link/email_link_screen_test.dart @@ -200,6 +200,69 @@ void main() { .called(1); }); + group('KTO-01 Kriterium 8: recovery links', () { + const recovery = '/auth/confirm?token_hash=abc123&type=recovery'; + + testWidgets('opening the page does not redeem the link (E3)', ( + tester, + ) async { + await open(tester, location: recovery); + + expect(find.text(_de.authRecoveryBody), findsOneWidget); + verifyNever(() => repository.verifyEmailLink(any(), any())); + }); + + testWidgets('an expired or used recovery link offers a new reset link', ( + tester, + ) async { + when(() => repository.verifyEmailLink(any(), any())).thenAnswer( + (_) async => failed(const AuthFailure.linkInvalidOrExpired()), + ); + final (:container, :router) = await open(tester, location: recovery); + + await tapSubmit(tester); + + expect(find.text(_de.authErrorLinkInvalidOrExpired), findsOneWidget); + expect(find.byKey(const Key('email_link_submit')), findsNothing); + // The title stays the recovery one. + expect(find.text(_de.authRecoveryTitle), findsOneWidget); + + await tester.tap(find.text(_de.authActionRequestNewLink)); + await tester.pumpAndSettle(); + expect(currentPath(router), '/forgot-password'); + }); + + testWidgets('a successful redeem shows no confirmation (router moves on)', ( + tester, + ) async { + await open(tester, location: recovery); + + await tapSubmit(tester); + + expect(find.byKey(const Key('email_link_success_banner')), findsNothing); + expect(find.byKey(const Key('email_link_continue')), findsNothing); + }); + + testWidgets('english recovery texts', (tester) async { + await open(tester, location: recovery, locale: const Locale('en')); + + expect(find.text(_en.authRecoveryTitle), findsOneWidget); + expect(find.text(_en.authRecoverySubmit), findsOneWidget); + }); + + testWidgets('the recovery error state meets the guidelines', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await open(tester, location: '/auth/confirm?type=recovery'); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + }); + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { await open(tester, locale: const Locale('en')); diff --git a/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart b/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart new file mode 100644 index 0000000..b2c1210 --- /dev/null +++ b/app/test/features/auth/presentation/forgot_password/forgot_password_controller_test.dart @@ -0,0 +1,134 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/presentation/forgot_password/forgot_password_controller.dart'; +import 'package:shuffle/features/auth/presentation/forgot_password/forgot_password_state.dart'; + +import '../auth_ui_support.dart'; + +void main() { + late MockAuthRepository repository; + late ProviderContainer container; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + container = ProviderContainer.test(overrides: authUiOverrides(repository)); + keepAlive(container, forgotPasswordControllerProvider); + }); + + ForgotPasswordController notifier() => + container.read(forgotPasswordControllerProvider.notifier); + ForgotPasswordState state() => + container.read(forgotPasswordControllerProvider); + + void stubReset(Result result) => + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => result); + + group('KTO-01 Kriterium 8: request the reset link', () { + test('sends the trimmed address and continues', () async { + stubReset(ok()); + notifier().emailChanged(' Anna@Example.com '); + + expect(await notifier().submit(), isTrue); + + verify(() => repository.requestPasswordReset('Anna@Example.com')) + .called(1); + expect(state().bannerFailure, isNull); + expect(state().isSubmitting, isFalse); + }); + + test('an invalid address is not sent and gets the focus', () async { + notifier().emailChanged('keine-adresse'); + + expect(await notifier().submit(), isFalse); + + verifyNever(() => repository.requestPasswordReset(any())); + expect(state().shownEmailError, isTrue); + expect(state().focusRequest, 1); + expect(state().isSubmitting, isFalse); + }); + + test('the error shows only after blur or submit', () { + notifier().emailChanged('keine-adresse'); + expect(state().shownEmailError, isFalse); + + notifier().emailBlurred(); + expect(state().shownEmailError, isTrue); + }); + + test('a second submit while one runs does nothing', () async { + final completer = Completer>(); + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) => completer.future); + notifier().emailChanged('anna@example.com'); + + final first = notifier().submit(); + expect(await notifier().submit(), isFalse); + completer.complete(ok()); + + expect(await first, isTrue); + verify(() => repository.requestPasswordReset(any())).called(1); + }); + }); + + group('KTO-01 Kriterium 4, F12: neutral reaction', () { + test('emailSendLimited counts as success (no hint, no error)', () async { + stubReset(failed(const AuthFailure.emailSendLimited())); + notifier().emailChanged('anna@example.com'); + + expect(await notifier().submit(), isTrue); + + expect(state().bannerFailure, isNull); + expect(state().shownEmailError, isFalse); + }); + + test('the IP rate limit is a visible failure', () async { + stubReset(failed(const AuthFailure.rateLimited())); + notifier().emailChanged('anna@example.com'); + + expect(await notifier().submit(), isFalse); + + expect(state().bannerFailure, const AuthFailure.rateLimited()); + }); + + test('offline is a visible failure and keeps the input', () async { + stubReset(failed(const AuthFailure.network())); + notifier().emailChanged('anna@example.com'); + + expect(await notifier().submit(), isFalse); + + expect(state().bannerFailure, const AuthFailure.network()); + expect(state().email, 'anna@example.com'); + }); + + test('a server-side invalid address is shown at the field', () async { + stubReset(failed(const AuthFailure.invalidEmail())); + notifier().emailChanged('anna@example.com'); + + expect(await notifier().submit(), isFalse); + + expect(state().serverEmailInvalid, isTrue); + expect(state().shownEmailError, isTrue); + expect(state().bannerFailure, isNull); + + notifier().emailChanged('anna@example.org'); + expect(state().serverEmailInvalid, isFalse); + }); + }); + + test('toString masks the address', () { + notifier().emailChanged('geheim@example.com'); + + final text = state().toString(); + + expect(text, isNot(contains('geheim'))); + expect(text, contains('')); + }); +} diff --git a/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart b/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart new file mode 100644 index 0000000..5ac7310 --- /dev/null +++ b/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + String location = '/forgot-password', + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + }) => pumpAuthRoute( + tester, + repository, + location: location, + locale: locale, + themeMode: themeMode, + textScale: textScale, + size: size, + ); + + Future send(WidgetTester tester, String email) async { + await tester.ensureVisible(fieldOf('forgot_password_email')); + await tester.enterText(fieldOf('forgot_password_email'), email); + await tester.pump(); + await tester.ensureVisible(find.byKey(const Key('forgot_password_submit'))); + await tester.tap(find.byKey(const Key('forgot_password_submit'))); + await tester.pumpAndSettle(); + } + + group('KTO-01 Kriterium 8: request the reset link', () { + testWidgets('shows title, explanation, field and button', (tester) async { + await open(tester); + + expect(find.text(_de.authForgotTitle), findsOneWidget); + expect(find.text(_de.authForgotBody), findsOneWidget); + expect(find.byKey(const Key('forgot_password_email')), findsOneWidget); + expect(find.text(_de.authForgotSubmit), findsOneWidget); + }); + + testWidgets('a valid address leads to check-your-inbox (reset mode)', ( + tester, + ) async { + final (:container, :router) = await open(tester); + + await send(tester, ' anna@example.com '); + + verify(() => repository.requestPasswordReset('anna@example.com')) + .called(1); + expect(currentPath(router), '/forgot-password/check-email'); + expect(find.text(_de.authCheckEmailBodyReset), findsOneWidget); + expect(find.byKey(const Key('check_email_resend')), findsNothing); + }); + + testWidgets('an invalid address shows the error and sends nothing', ( + tester, + ) async { + final (:container, :router) = await open(tester); + + await send(tester, 'keine-adresse'); + + expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); + expect(currentPath(router), '/forgot-password'); + verifyNever(() => repository.requestPasswordReset(any())); + }); + + testWidgets('the button is locked while sending', (tester) async { + final completer = Completer>(); + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) => completer.future); + await open(tester); + await tester.enterText( + fieldOf('forgot_password_email'), + 'anna@example.com', + ); + await tester.tap(find.byKey(const Key('forgot_password_submit'))); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester.widget(fieldOf('forgot_password_email')).enabled, + isFalse, + ); + completer.complete(ok()); + await tester.pumpAndSettle(); + verify(() => repository.requestPasswordReset(any())).called(1); + }); + + testWidgets('offline shows the hint, keeps the input, can retry', ( + tester, + ) async { + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + + await send(tester, 'anna@example.com'); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect( + tester + .widget(fieldOf('forgot_password_email')) + .controller! + .text, + 'anna@example.com', + ); + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => ok()); + await tester.tap(find.text(_de.authActionRetry)); + await tester.pumpAndSettle(); + verify(() => repository.requestPasswordReset(any())).called(2); + }); + + testWidgets('the IP rate limit shows the rate-limit hint', (tester) async { + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); + final (:container, :router) = await open(tester); + + await send(tester, 'anna@example.com'); + + expect(find.text(_de.authErrorRateLimited), findsOneWidget); + expect(currentPath(router), '/forgot-password'); + }); + }); + + group('KTO-01 Kriterium 4, F12: neutral reaction', () { + testWidgets( + 'known, unknown and mail-limited addresses show exactly the same page', + (tester) async { + Future> pageFor( + Result result, + String email, + ) async { + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => result); + final (:container, :router) = await open(tester); + await send(tester, email); + expect(currentPath(router), '/forgot-password/check-email'); + final texts = visibleTexts(tester); + // Start from a fresh tree for the next case. + await tester.pumpWidget(const SizedBox()); + return texts; + } + + // 200 for an existing account and for an unknown address look the same + // for the app; the mail-limit answer must not differ either. + final known = await pageFor(ok(), 'bekannt@example.com'); + final unknown = await pageFor(ok(), 'unbekannt@example.com'); + final limited = await pageFor( + failed(const AuthFailure.emailSendLimited()), + 'bekannt@example.com', + ); + + expect(unknown, known); + expect(limited, known); + expect(known, contains(_de.authCheckEmailBodyReset)); + expect(known, contains(_de.authCheckEmailDelayHint)); + expect(known, isNot(contains(_de.authErrorRateLimited))); + expect(known.join(' '), isNot(contains('example.com'))); + }, + ); + + testWidgets('emailSendLimited shows no rate-limit hint on the form', ( + tester, + ) async { + when( + () => repository.requestPasswordReset(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + await open(tester); + + await send(tester, 'anna@example.com'); + + expect(find.text(_de.authErrorRateLimited), findsNothing); + expect(find.text(_de.authCheckEmailBodyReset), findsOneWidget); + }); + }); + + group('Navigation and from', () { + testWidgets('back leads to /login and carries a safe from', (tester) async { + final (:container, :router) = await open( + tester, + location: '/forgot-password?from=%2Fprivate%3Fa%3D1', + ); + + await tester.tap(find.byKey(const Key('forgot_password_back'))); + await tester.pumpAndSettle(); + + expect(currentUri(router).toString(), '/login?from=%2Fprivate%3Fa%3D1'); + }); + + testWidgets('an unsafe from is dropped', (tester) async { + final (:container, :router) = await open( + tester, + location: '/forgot-password?from=https%3A%2F%2Fevil.example', + ); + + await tester.tap(find.byKey(const Key('forgot_password_back'))); + await tester.pumpAndSettle(); + + expect(currentUri(router).toString(), '/login'); + }); + }); + + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authForgotTitle), findsOneWidget); + expect(find.text(_en.authForgotBody), findsOneWidget); + expect(find.text(_en.authForgotSubmit), findsOneWidget); + expect(find.text(_de.authForgotTitle), findsNothing); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester, themeMode: mode); + await send(tester, 'anna@example.com'); + expect(find.byKey(const Key('forgot_password_banner')), findsOneWidget); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('the title is a heading', (tester) async { + final handle = tester.ensureSemantics(); + await open(tester); + + expect( + tester.getSemantics(find.text(_de.authForgotTitle).first), + matchesSemantics(label: _de.authForgotTitle, isHeader: true), + ); + handle.dispose(); + }); + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + when(() => repository.requestPasswordReset(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester, textScale: 2, size: const Size(320, 640)); + await send(tester, 'anna@example.com'); + + expect(tester.takeException(), isNull); + }); + + testWidgets('wide screens show a centered card', (tester) async { + await open(tester, size: const Size(1200, 900)); + + final card = tester.getRect(find.byType(Card).first); + expect(card.width, lessThanOrEqualTo(480)); + expect(card.center.dx, closeTo(600, 1)); + }); + }); +} diff --git a/app/test/features/auth/presentation/login/login_screen_test.dart b/app/test/features/auth/presentation/login/login_screen_test.dart index 54fe2ce..e49bae5 100644 --- a/app/test/features/auth/presentation/login/login_screen_test.dart +++ b/app/test/features/auth/presentation/login/login_screen_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' show Tristate; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -216,6 +217,48 @@ void main() { }); }); + group('from is carried to register and forgot password', () { + testWidgets('a safe from goes along and comes back', (tester) async { + final (:container, :router) = await open( + tester, + location: '/login?from=%2Fprivate', + ); + + await tapKey(tester, 'login_register_link'); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/register?from=%2Fprivate'); + await tapKey(tester, 'register_login_link'); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/login?from=%2Fprivate'); + + await tapKey(tester, 'login_forgot_link'); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/forgot-password?from=%2Fprivate'); + await tapKey(tester, 'forgot_password_back'); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/login?from=%2Fprivate'); + }); + + testWidgets('an unsafe from is not forwarded', (tester) async { + final (:container, :router) = await open( + tester, + location: '/login?from=%2F%2Fevil.example', + ); + + await tapKey(tester, 'login_register_link'); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/register'); + }); + + testWidgets('without from the links stay plain', (tester) async { + final (:container, :router) = await open(tester); + + await tapKey(tester, 'login_forgot_link'); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/forgot-password'); + }); + }); + group('KTO-01 Kriterium 7: generic failure', () { testWidgets('invalid credentials: one banner, no field error, input kept', ( tester, @@ -346,6 +389,7 @@ void main() { }); testWidgets('the resend action is locked while sending', (tester) async { + final semanticsHandle = tester.ensureSemantics(); final completer = Completer>(); when(() => repository.resendConfirmation(any())) .thenAnswer((_) => completer.future); @@ -356,7 +400,23 @@ void main() { await tester.tap(find.text(_de.authActionResendConfirmation)); await tester.pump(); - expect(find.text(_de.authActionResendConfirmation), findsNothing); + // Still there for screen readers, but disabled: no second send. + final action = find.widgetWithText( + TextButton, + _de.authActionResendConfirmation, + ); + expect(action, findsOneWidget); + expect(tester.widget(action).onPressed, isNull); + expect( + tester + .getSemantics(action) + .getSemanticsData() + .flagsCollection + .isEnabled, + Tristate.isFalse, + ); + await tester.tap(action, warnIfMissed: false); + semanticsHandle.dispose(); completer.complete(ok()); await tester.pumpAndSettle(); verify(() => repository.resendConfirmation(any())).called(1); diff --git a/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart b/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart new file mode 100644 index 0000000..bbfb254 --- /dev/null +++ b/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart @@ -0,0 +1,218 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:shuffle/features/auth/presentation/reset_password/reset_password_controller.dart'; +import 'package:shuffle/features/auth/presentation/reset_password/reset_password_state.dart'; + +import '../auth_ui_support.dart'; + +const _user = AuthUser( + id: 'u1', + email: 'anna@example.com', + emailConfirmed: true, +); + +void main() { + late MockAuthRepository repository; + late ProviderContainer container; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.currentStatus) + .thenReturn(const AuthStatus.passwordRecovery(_user)); + when(() => repository.watchStatus()) + .thenAnswer((_) => const Stream.empty()); + container = ProviderContainer.test(overrides: authUiOverrides(repository)); + keepAlive(container, resetPasswordControllerProvider); + }); + + ResetPasswordController notifier() => + container.read(resetPasswordControllerProvider.notifier); + ResetPasswordState state() => container.read(resetPasswordControllerProvider); + + void stubSet(Result result) => + when(() => repository.setNewPassword(any())) + .thenAnswer((_) async => result); + + group('KTO-01 Kriterium 3: password policy', () { + test('too short is rejected locally and shows the missing count', () async { + notifier().passwordChanged('kurz'); + + expect(await notifier().submit(), isFalse); + + verifyNever(() => repository.setNewPassword(any())); + expect(state().shownPasswordError, isNotNull); + expect(state().focusRequest, 1); + expect(state().isSubmitting, isFalse); + }); + + test('exactly 10 characters pass', () async { + stubSet(ok()); + notifier().passwordChanged('1234567890'); + + expect(await notifier().submit(), isTrue); + + verify(() => repository.setNewPassword('1234567890')).called(1); + }); + + test( + 'a common password is rejected (the list loads before submit)', + () async { + notifier().passwordChanged('Passwort1234'); + + expect(await notifier().submit(), isFalse); + + verifyNever(() => repository.setNewPassword(any())); + expect(state().passwordError, isNotNull); + }, + ); + + test('an empty password is "required" after a submit attempt', () async { + expect(await notifier().submit(), isFalse); + + expect(state().shownPasswordRequired, isTrue); + }); + + test('the error shows while typing and after blur', () { + expect(state().shownPasswordError, isNull); + notifier().passwordChanged('abc'); + expect(state().shownPasswordError, isNotNull); + }); + + test('the password is sent unchanged (no trimming)', () async { + stubSet(ok()); + notifier().passwordChanged(' zehn zeichen '); + + await notifier().submit(); + + verify(() => repository.setNewPassword(' zehn zeichen ')).called(1); + }); + }); + + group('KTO-01 Kriterium 8, 9: set the new password', () { + test( + 'success returns true and keeps the form locked from re-submits', + () async { + stubSet(ok()); + notifier().passwordChanged('ein-langes-passwort'); + + expect(await notifier().submit(), isTrue); + + expect(state().bannerFailure, isNull); + expect(state().serverPasswordReasons, isNull); + }, + ); + + test('weak_password from the server is shown at the field', () async { + stubSet( + failed(const AuthFailure.weakPassword([WeakPasswordReason.length])), + ); + notifier().passwordChanged('ein-langes-passwort'); + + expect(await notifier().submit(), isFalse); + + expect(state().serverPasswordReasons, [WeakPasswordReason.length]); + expect(state().bannerFailure, isNull); + notifier().passwordChanged('ein-anderes-passwort'); + expect(state().serverPasswordReasons, isNull); + }); + + test('same_password is shown at the field', () async { + stubSet(failed(const AuthFailure.samePassword())); + notifier().passwordChanged('ein-langes-passwort'); + + expect(await notifier().submit(), isFalse); + + expect(state().serverSamePassword, isTrue); + expect(state().bannerFailure, isNull); + notifier().passwordChanged('ein-anderes-passwort'); + expect(state().serverSamePassword, isFalse); + }); + + for (final failure in [ + const AuthFailure.network(), + const AuthFailure.unexpected(), + const AuthFailure.rateLimited(), + ]) { + test('${failure.runtimeType} is a banner and keeps the input', () async { + stubSet(failed(failure)); + notifier().passwordChanged('ein-langes-passwort'); + + expect(await notifier().submit(), isFalse); + + expect(state().bannerFailure, failure); + expect(state().password, 'ein-langes-passwort'); + expect(state().isSubmitting, isFalse); + }); + } + + test('a second submit while one runs does nothing', () async { + final completer = Completer>(); + when(() => repository.setNewPassword(any())) + .thenAnswer((_) => completer.future); + notifier().passwordChanged('ein-langes-passwort'); + + final first = notifier().submit(); + expect(await notifier().submit(), isFalse); + completer.complete(ok()); + + expect(await first, isTrue); + verify(() => repository.setNewPassword(any())).called(1); + }); + }); + + group('KTO-01 Kriterium 8: finish and cancel', () { + test('finish ends the recovery guard', () { + notifier().finish(); + + verify(() => repository.finishPasswordRecovery()).called(1); + }); + + test('cancel signs out', () async { + when(() => repository.signOut()).thenAnswer((_) async {}); + + await notifier().cancel(); + + verify(() => repository.signOut()).called(1); + }); + }); + + group('passwordRecoveryCompleted', () { + test('is true only for a recovery with the changed password', () async { + final events = StreamController(); + addTearDown(events.close); + when(() => repository.watchStatus()).thenAnswer((_) => events.stream); + final c = ProviderContainer.test(overrides: authUiOverrides(repository)); + c.listen(passwordRecoveryCompletedProvider, (_, _) {}); + + expect(c.read(passwordRecoveryCompletedProvider), isFalse); + + events.add( + const AuthStatus.passwordRecovery(_user, passwordChanged: true), + ); + await Future.delayed(Duration.zero); + expect(c.read(passwordRecoveryCompletedProvider), isTrue); + + events.add(const AuthStatus.signedIn(_user)); + await Future.delayed(Duration.zero); + expect(c.read(passwordRecoveryCompletedProvider), isFalse); + }); + }); + + test('toString masks the password', () { + notifier().passwordChanged('geheimes-passwort'); + + final text = state().toString(); + + expect(text, isNot(contains('geheimes'))); + expect(text, contains('')); + }); +} diff --git a/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart b/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart new file mode 100644 index 0000000..b185309 --- /dev/null +++ b/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart @@ -0,0 +1,357 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:shuffle/features/auth/presentation/auth_routes.dart'; +import 'package:shuffle/l10n/app_localizations_de.dart'; +import 'package:shuffle/l10n/app_localizations_en.dart'; + +import '../../../../helpers/pump_app.dart'; +import '../../../../helpers/router_helpers.dart'; +import '../auth_ui_support.dart'; + +final _de = AppLocalizationsDe(); +final _en = AppLocalizationsEn(); + +const _user = AuthUser( + id: 'u1', + email: 'anna@example.com', + emailConfirmed: true, +); +const _recovery = AuthStatus.passwordRecovery(_user); +const _changed = AuthStatus.passwordRecovery(_user, passwordChanged: true); + +void main() { + late MockAuthRepository repository; + late StreamController status; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + status = StreamController.broadcast(); + when(() => repository.currentStatus).thenReturn(_recovery); + when(() => repository.watchStatus()).thenAnswer((_) => status.stream); + when(() => repository.setNewPassword(any())).thenAnswer((_) async => ok()); + when(() => repository.signOut()).thenAnswer((_) async {}); + }); + + tearDown(() => status.close()); + + Future open( + WidgetTester tester, { + Locale locale = const Locale('de'), + ThemeMode themeMode = ThemeMode.light, + double? textScale, + Size size = const Size(400, 800), + }) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + initialLocation: '/reset-password', + routes: [ + ...authRoutes, + GoRoute(path: '/', builder: (_, _) => const Text('home-page')), + ], + ); + addTearDown(router.dispose); + final container = await pumpApp( + tester, + router: router, + locale: locale, + themeMode: themeMode, + textScale: textScale, + overrides: authUiOverrides(repository, locale: locale), + ); + return (container: container, router: router); + } + + Future type(WidgetTester tester, String text) async { + await tester.ensureVisible(fieldOf('reset_password_password')); + await tester.enterText(fieldOf('reset_password_password'), text); + await tester.pump(); + } + + Future save(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const Key('reset_password_submit'))); + await tester.tap(find.byKey(const Key('reset_password_submit'))); + await tester.pumpAndSettle(); + } + + /// What the real repository does on a successful `updateUser`. + void passwordChangedOnServer() { + when(() => repository.currentStatus).thenReturn(_changed); + status.add(_changed); + } + + group('KTO-01 Kriterium 3: policy at the field', () { + testWidgets('shows the rule hint and the missing count while typing', ( + tester, + ) async { + await open(tester); + expect(find.text(_de.authPasswordRule), findsOneWidget); + + await type(tester, 'kurz'); + + expect(find.text(_de.authPasswordTooShort(6)), findsOneWidget); + }); + + testWidgets('a common password is explained at the field', (tester) async { + await open(tester); + + await type(tester, 'passwort1234'); + await save(tester); + + expect(find.text(_de.authPasswordCommon), findsOneWidget); + verifyNever(() => repository.setNewPassword(any())); + }); + + testWidgets('an empty submit says a password is required', (tester) async { + await open(tester); + + await save(tester); + + expect(find.text(_de.authPasswordRequired), findsOneWidget); + expect( + tester + .widget(fieldOf('reset_password_password')) + .focusNode! + .hasFocus, + isTrue, + ); + }); + + testWidgets('server weak_password and same_password show at the field', ( + tester, + ) async { + when(() => repository.setNewPassword(any())).thenAnswer( + (_) async => + failed(const AuthFailure.weakPassword([WeakPasswordReason.length])), + ); + await open(tester); + await type(tester, 'ein-langes-passwort'); + await save(tester); + expect(find.text(_de.authErrorWeakPasswordLength), findsOneWidget); + + when(() => repository.setNewPassword(any())) + .thenAnswer((_) async => failed(const AuthFailure.samePassword())); + await type(tester, 'ein-anderes-passwort'); + await save(tester); + expect(find.text(_de.authErrorSamePassword), findsOneWidget); + }); + }); + + group('KTO-01 Kriterium 8, 9: set the password', () { + testWidgets('shows the form with title, field, save and cancel', ( + tester, + ) async { + await open(tester); + + expect(find.text(_de.authResetTitle), findsOneWidget); + expect(find.text(_de.authFieldNewPasswordLabel), findsWidgets); + expect(find.text(_de.authResetSubmit), findsOneWidget); + expect(find.text(_de.authResetCancel), findsOneWidget); + expect( + find.byKey(const Key('reset_password_success_banner')), + findsNothing, + ); + }); + + testWidgets('after the change the honest hint and a continue button show', ( + tester, + ) async { + final (:container, :router) = await open(tester); + await type(tester, 'ein-langes-passwort'); + + await save(tester); + passwordChangedOnServer(); + await tester.pumpAndSettle(); + + verify(() => repository.setNewPassword('ein-langes-passwort')).called(1); + expect(find.text(_de.authPasswordChanged), findsOneWidget); + expect(find.byKey(const Key('reset_password_password')), findsNothing); + // No cancel in the success view: it would sign out a valid session. + expect(find.byKey(const Key('reset_password_cancel')), findsNothing); + expect(currentPath(router), '/reset-password'); + }); + + test('the hint is honest: no "immediately", mentions an hour', () { + for (final text in [_de.authPasswordChanged, _en.authPasswordChanged]) { + expect(text.toLowerCase(), isNot(contains('sofort'))); + expect(text.toLowerCase(), isNot(contains('immediately'))); + expect(text.toLowerCase(), isNot(contains('instantly'))); + } + expect(_de.authPasswordChanged, contains('Stunde')); + expect(_en.authPasswordChanged, contains('hour')); + }); + + testWidgets('continue ends the guard and leaves for the start page', ( + tester, + ) async { + when(() => repository.currentStatus).thenReturn(_changed); + final (:container, :router) = await open(tester); + status.add(_changed); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('reset_password_continue'))); + await tester.pumpAndSettle(); + + verify(() => repository.finishPasswordRecovery()).called(1); + expect(currentPath(router), '/'); + }); + + testWidgets('the success view survives a rebuilt screen', (tester) async { + when(() => repository.currentStatus).thenReturn(_changed); + + await open(tester); + + expect(find.text(_de.authPasswordChanged), findsOneWidget); + }); + + testWidgets('cancel signs out', (tester) async { + await open(tester); + + await tester.tap(find.byKey(const Key('reset_password_cancel'))); + await tester.pumpAndSettle(); + + verify(() => repository.signOut()).called(1); + }); + + testWidgets('the button is locked while saving', (tester) async { + final completer = Completer>(); + when(() => repository.setNewPassword(any())) + .thenAnswer((_) => completer.future); + await open(tester); + await type(tester, 'ein-langes-passwort'); + + await tester.tap(find.byKey(const Key('reset_password_submit'))); + await tester.pump(); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester.widget(fieldOf('reset_password_password')).enabled, + isFalse, + ); + expect( + tester + .widget(find.byKey(const Key('reset_password_cancel'))) + .onPressed, + isNull, + ); + completer.complete(ok()); + await tester.pumpAndSettle(); + verify(() => repository.setNewPassword(any())).called(1); + }); + + testWidgets('offline shows the hint, keeps the input, can retry', ( + tester, + ) async { + when(() => repository.setNewPassword(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + await type(tester, 'ein-langes-passwort'); + + await save(tester); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect( + tester + .widget(fieldOf('reset_password_password')) + .controller! + .text, + 'ein-langes-passwort', + ); + when(() => repository.setNewPassword(any())) + .thenAnswer((_) async => ok()); + await tester.tap(find.text(_de.authActionRetry)); + await tester.pumpAndSettle(); + verify(() => repository.setNewPassword(any())).called(2); + }); + }); + + testWidgets('QUE-05 Kriterium 2: english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authResetTitle), findsOneWidget); + expect(find.text(_en.authResetSubmit), findsOneWidget); + expect(find.text(_en.authResetCancel), findsOneWidget); + expect(find.text(_de.authResetTitle), findsNothing); + }); + + group('QUE-06 Kriterien 1-4: accessibility', () { + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('form meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + when(() => repository.setNewPassword(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester, themeMode: mode); + await type(tester, 'ein-langes-passwort'); + await save(tester); + expect(find.byKey(const Key('reset_password_banner')), findsOneWidget); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('success view meets the guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + when(() => repository.currentStatus).thenReturn(_changed); + await open(tester, themeMode: mode); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('title is a heading, success is announced as a live region', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + when(() => repository.currentStatus).thenReturn(_changed); + await open(tester); + + expect( + tester.getSemantics(find.text(_de.authResetTitle).first), + matchesSemantics(label: _de.authResetTitle, isHeader: true), + ); + expect( + find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'), + findsOneWidget, + ); + handle.dispose(); + }); + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + await open(tester, textScale: 2, size: const Size(320, 640)); + await type(tester, 'kurz'); + expect(tester.takeException(), isNull); + + when(() => repository.currentStatus).thenReturn(_changed); + status.add(_changed); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('wide screens show a centered card', (tester) async { + await open(tester, size: const Size(1200, 900)); + + final card = tester.getRect(find.byType(Card).first); + expect(card.width, lessThanOrEqualTo(480)); + expect(card.center.dx, closeTo(600, 1)); + }); + }); +} diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 473b832..6c755e1 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -54,9 +54,14 @@ Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auc ! Fehler-Banner bei rateLimited / network (nur wenn E-Mail fehlt, z. B. nach Reload:) E-Mail [ ] ( E-Mail erneut senden ) -> nur Modus Bestätigung, gesperrt solange Anfrage läuft - (Modus Reset: -> /forgot-password) + (Modus Reset: statt des Resend-Knopfs -> /forgot-password) ``` +Der Modus steckt im **Pfad**, nicht im `extra`: `/register/check-email` (Bestätigung, E-Mail als `extra`) und +`/forgot-password/check-email` (Reset, ohne `extra`). So überlebt der Modus einen Reload im Web. Im Modus Reset gibt es +keinen Resend (Spec § 5.4), Text `authCheckEmailBodyReset`, Gültigkeit und Zusatz „kann einige Minuten dauern“ wie im +Modus Bestätigung; Keys `check_email_different_address` (statt `check_email_resend`). + ### LoginScreen (`/login`, umgesetzt in 01b) ``` @@ -77,8 +82,11 @@ Umsetzungsentscheidungen: ist identisch (Krit. 7, Test vergleicht alle sichtbaren Texte). Felder zeigen nur lokale Fehler (leer / E-Mail-Syntax). - Bei `emailNotConfirmed` merkt sich der Controller die E-Mail des Versuchs; "erneut senden" nutzt diese Adresse (`ResendController`, `ResendOrigin.login`), nicht den aktuellen Feldinhalt. Dort wird `emailSendLimited` als - Drosselungshinweis gezeigt (F12). Während des Sendens ist die Aktion ausgeblendet (kein Doppelversand). + Drosselungshinweis gezeigt (F12). Während des Sendens ist die Aktion **deaktiviert, aber sichtbar** (`AppBanner.actionEnabled: false`): + kein Doppelversand, und der Fokus eines Screenreaders verschwindet nicht mit dem Knopf (Semantics: Knopf, nicht aktiviert). - Ein neuer Anmeldeversuch setzt das Resend-Ergebnis zurück. +- `from` wird an „Konto erstellen“ und „Passwort vergessen?“ weitergegeben (`authLocation`, geprüft mit `safeFromPath`); + Register- und ForgotPassword-Screen geben es mit „Anmelden“ bzw. „Zurück zur Anmeldung“ an `/login` zurück. - Erfolg: `context.go()`. `from` wird mit `safeFromPath` geprüft (nur interne Pfade, keine Auth-Seiten, kein `//`, `/\` oder Schema); sonst Startseite. Zusätzlich leitet der Router bei Status `signedIn` von `/login` um. - Autofill: `AutofillGroup`, E-Mail `username` + `email`, Passwort `password`; `TextInput.finishAutofillContext()` bei Erfolg. @@ -93,9 +101,16 @@ Passwort zurücksetzen Gib die E-Mail-Adresse deines Kontos ein. Wir senden dir einen Link zum Zurücksetzen. E-Mail [ ] Fehler: authEmailInvalid ( Link senden ) - + -> /login (mit from) ``` +Umsetzung: Nach jedem 200 **und** bei `emailSendLimited` (F12) geht es ohne Hinweis zu `/forgot-password/check-email` +(Antwort für bekannte und unbekannte Adresse sowie für die Mail-Sperre ist identisch, getestet per exaktem +Textvergleich). Sichtbare Fehler nur für lokale Syntax, `invalidEmail` (am Feld), `rateLimited`/`network`/`unexpected` +(Banner, bei den beiden letzten mit „Erneut versuchen“, während des Sendens deaktiviert). Die Adresse wird nicht an +die Folgeseite übergeben. Keys: `forgot_password_screen`, `forgot_password_email`, `forgot_password_submit`, +`forgot_password_banner`, `forgot_password_body`, `forgot_password_back`. + ### EmailLinkScreen (`/auth/confirm`) Zustände: `idle` (Knopf), `submitting`, `success` (nur Bestätigung), `error` (Link ungültig). @@ -109,6 +124,10 @@ Erfolg: ! Info-Banner "E-Mail bestätigt" (+ Mobile-Web: "Du kannst dich jetzt Fehler: ! Fehler-Banner "Der Link ist abgelaufen ..." ( Neuen Link anfordern ) ``` +Typ Recovery (01c): Titel, Text und Knopf aus `authRecovery*`; das Einlösen passiert erst per Klick. Bei Erfolg zeigt die +Seite nichts weiter an, der Statuswechsel `passwordRecovery` leitet nach `/reset-password`. Ungültiger oder abgelaufener +Link (`otp_expired`) → Fehlerzustand mit „Neuen Link anfordern“ → `/forgot-password` (Bestätigung: `/login`). + ### ResetPasswordScreen (`/reset-password`) ``` @@ -116,10 +135,29 @@ Neues Passwort festlegen Neues Passwort [ ••••••••• (Auge) ] Live-Regeln: authPasswordRule, Fehler je Regel / samePassword ( Passwort speichern ) - -> signOut -Erfolg: Hinweis authPasswordChanged, dann Weiterleitung nach / + -> signOut -> /login + +Erfolg (Ansicht ersetzt das Formular, kein Abbrechen mehr): +Passwort geändert. (Hinweis authPasswordChanged) +( Weiter ) -> / ``` +Umsetzungsentscheidungen (01c): + +- **Der Redirect nach `userUpdated` wäre ohne Eingriff ein Fehler:** GoTrue sendet `userUpdated`, bevor `updateUser` + zurückkehrt; die Regel „angemeldet auf `/reset-password` → `/`“ hätte die Seite entfernt, bevor die Erfolgsmeldung + (Krit. 9) sichtbar ist. Lösung: `userUpdated` während der Recovery beendet den Guard **nicht**, sondern setzt + `AuthStatus.passwordRecovery(passwordChanged: true)`. Erst „Weiter“ (`AuthRepository.finishPasswordRecovery()`) beendet den + Guard; der Router leitet dann nach `/`. Die Erfolgsansicht leitet sich aus dem Status ab (`passwordRecoveryCompleted`), + überlebt also einen Neuaufbau der Seite. Bewusst **kein** zeitgesteuerter Sprung (Screenreader, Lesezeit). Spec § 5.5 + ist entsprechend ergänzt. +- „Abbrechen“ gibt es nur im Formular, nicht in der Erfolgsansicht (es würde eine gültige Sitzung abmelden). +- Passwortregel wie bei der Registrierung (10 Zeichen, Liste häufiger Passwörter; die Liste wird vor dem Absenden + geladen, Fallback nur Länge). `weak_password` und `same_password` stehen am Feld, alles andere im Banner. +- Hinweistext ehrlich zu F1: andere Geräte können bis zu etwa einer Stunde angemeldet bleiben; nichts von „sofort“. +- Keys: `reset_password_screen`, `reset_password_password`, `reset_password_submit`, `reset_password_cancel`, + `reset_password_banner`, `reset_password_success_banner`, `reset_password_continue`. + ## 3. Textvorschläge Ton: du-Form, kurz, sachlich-freundlich, keine Schuldzuweisung. Platzhalter in ICU-Syntax (`{n}`). @@ -217,7 +255,7 @@ teilt – Entscheidung beim flutter-dev (A2). | `authResetTitle` | Neues Passwort festlegen | Set a new password | | `authResetSubmit` | Passwort speichern | Save password | | `authResetCancel` | Abbrechen | Cancel | -| `authPasswordChanged` | Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern. | Password changed. Other devices will be signed out; this can take up to an hour. | +| `authPasswordChanged` | Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet. | Password changed. On other devices you will be signed out after about an hour at the latest. | ## 4. Barrierefreiheit pro Screen (Checkliste für A2–A4 und T2) diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 3998809..02cf63f 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -224,6 +224,7 @@ Future> signIn(String email, String password); Future> requestPasswordReset(String email); Future> verifyEmailLink(String tokenHash, EmailLinkType type); // confirmation | recovery Future> setNewPassword(String password); +void finishPasswordRecovery(); // beendet den Recovery-Guard nach dem Passwortwechsel (01c, siehe 5.5) Future signOut(); // genutzt bei abgebrochener Recovery; das UI zum Abmelden kommt mit KTO-11 ``` @@ -292,6 +293,7 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy). - `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`. - **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12). +- **Ergänzung 01c (bewusst):** Auch `userUpdated` beendet den Guard nicht mehr. GoTrue sendet es, bevor `updateUser` zurückkehrt; der Redirect „angemeldet auf `/reset-password` → `/`“ hätte den Screen entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Der Tracker setzt stattdessen `AuthStatus.passwordRecovery(passwordChanged: true)`, der Router hält den Nutzer auf `/reset-password` (Erfolgsansicht mit Knopf „Weiter“), und erst `AuthRepository.finishPasswordRecovery()` beendet den Guard. `signedOut` (Abbrechen, Refresh-Fehler) beendet ihn weiterhin sofort. Der Zustand lebt weiter nur im Prozess (R12). - Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue` bzw. freezed-Formzustand. Keine Logik in Widgets. ### 5.6 Liste häufiger Passwörter (Krit. 3) -- 2.45.2 From fd4183c2026ec211494db1e0a747d9c09fb004c2 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 09:50:42 +0100 Subject: [PATCH 3/5] fix(auth): restore the reviewed success text for the password change Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/l10n/app_de.arb | 2 +- app/lib/l10n/app_en.arb | 2 +- app/lib/l10n/app_localizations.dart | 2 +- app/lib/l10n/app_localizations_de.dart | 2 +- app/lib/l10n/app_localizations_en.dart | 2 +- app/test/features/auth/presentation/auth_ui_support.dart | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 1091ebc..137aa26 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -77,5 +77,5 @@ "authResetTitle": "Neues Passwort festlegen", "authResetSubmit": "Passwort speichern", "authResetCancel": "Abbrechen", - "authPasswordChanged": "Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet." + "authPasswordChanged": "Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index cc4c8e4..9c2b1d9 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -123,5 +123,5 @@ "authResetTitle": "Set a new password", "authResetSubmit": "Save password", "authResetCancel": "Cancel", - "authPasswordChanged": "Password changed. On other devices you will be signed out after about an hour at the latest." + "authPasswordChanged": "Password changed. Other devices will be signed out; this can take up to an hour." } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2e9eda5..9442e16 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -563,7 +563,7 @@ abstract class AppLocalizations { /// No description provided for @authPasswordChanged. /// /// In en, this message translates to: - /// **'Password changed. On other devices you will be signed out after about an hour at the latest.'** + /// **'Password changed. Other devices will be signed out; this can take up to an hour.'** String get authPasswordChanged; } diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index f8071c1..091565c 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -281,5 +281,5 @@ class AppLocalizationsDe extends AppLocalizations { @override String get authPasswordChanged => - 'Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet.'; + 'Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index f272081..813c024 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -278,5 +278,5 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authPasswordChanged => - 'Password changed. On other devices you will be signed out after about an hour at the latest.'; + 'Password changed. Other devices will be signed out; this can take up to an hour.'; } diff --git a/app/test/features/auth/presentation/auth_ui_support.dart b/app/test/features/auth/presentation/auth_ui_support.dart index 91af0a6..d0f1a53 100644 --- a/app/test/features/auth/presentation/auth_ui_support.dart +++ b/app/test/features/auth/presentation/auth_ui_support.dart @@ -78,8 +78,8 @@ void keepAlive(ProviderContainer container, ProviderListenable p) { typedef AuthUiHandle = ({ProviderContainer container, GoRouter router}); -/// Pumps the real auth routes (register, check-email, confirm; the others are -/// placeholders) at [location] on a phone-sized screen. +/// Pumps the real auth routes (`authRoutes`, without router redirects) at +/// [location] on a phone-sized screen. Future pumpAuthRoute( WidgetTester tester, MockAuthRepository repository, { -- 2.45.2 From 3b2995463e6a51f60664b5d3ca06c39708109ab1 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 09:50:42 +0100 Subject: [PATCH 4/5] docs(auth): document 01c deviations, reset mode route and AppBanner actionEnabled Refs #1 Co-Authored-By: Claude Sonnet 5.5 --- docs/design-system.md | 2 +- docs/design/auth-screens.md | 2 +- docs/specs/1-auth-registrierung-anmeldung.md | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 0a8dab5..e65bc7d 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -76,7 +76,7 @@ getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vo | `AppCheckboxField` | `AppCheckboxField(value, onChanged, label)` bzw. `.rich(segments: [AppLabelSegment.text(..), AppLabelSegment.link(text, onTap)])`, `errorText`. Ganze Zeile (Checkbox + Label) ist Touch-Ziel >= 48 dp. Links sind per Tastatur fokussierbar und für Screenreader als benutzerdefinierte Aktionen verfügbar; Semantics-Knoten meldet Zustand und den vollen Labeltext. | | `AppPrimaryButton` | `label`, `onPressed`, `isLoading` (deaktiviert + Spinner, kein Doppelsenden), `loadingLabel` (gesprochener Text im Ladezustand), `icon`, `expand` (Standard: volle Breite). | | `AppFormScaffold` | `child`, `appBar`, `maxWidth` (Standard `AppBreakpoints.authCardMaxWidth`). Kompakt: Vollbild, scrollbar; ab 600 dp zentrierte Karte. Tastatur schiebt den Inhalt hoch (Scaffold-Resize), Ziehen schließt die Tastatur. | -| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton). Live-Region mit „typeLabel: message“. | +| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton), `actionEnabled` (Standard `true`; `false` lässt die Aktion sichtbar, aber deaktiviert, z. B. während eine Anfrage läuft; Semantics melden sie als nicht aktiviert, der Screenreader-Fokus geht nicht verloren). Live-Region mit „typeLabel: message“. | | `AppFieldError` | Interner Baustein: Fehlerzeile mit Icon als Live-Region, von Textfeld und Checkbox genutzt. | Auth-Screens: Layout-Skizzen und Textvorschläge siehe `docs/design/auth-screens.md`. diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 6c755e1..1f1c867 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -255,7 +255,7 @@ teilt – Entscheidung beim flutter-dev (A2). | `authResetTitle` | Neues Passwort festlegen | Set a new password | | `authResetSubmit` | Passwort speichern | Save password | | `authResetCancel` | Abbrechen | Cancel | -| `authPasswordChanged` | Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet. | Password changed. On other devices you will be signed out after about an hour at the latest. | +| `authPasswordChanged` | Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern. | Password changed. Other devices will be signed out; this can take up to an hour. | ## 4. Barrierefreiheit pro Screen (Checkliste für A2–A4 und T2) diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 02cf63f..7082078 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -239,6 +239,7 @@ Future signOut(); // genutzt bei abgebrochener Recovery; das UI zum Abmeld | `/register` | RegisterScreen | nur abgemeldet | | `/register/check-email` | CheckEmailScreen (E-Mail als `extra`, nicht in der URL) | öffentlich | | `/forgot-password` | ForgotPasswordScreen → danach CheckEmailScreen (Modus Reset) | nur abgemeldet | +| `/forgot-password/check-email` | CheckEmailScreen im Modus Reset (Modus im Pfad, kein `extra`, übersteht einen Reload) | öffentlich | | `/auth/confirm?token_hash&type` | EmailLinkScreen | öffentlich | | `/reset-password` | ResetPasswordScreen | **nur** im Status `passwordRecovery` | @@ -284,7 +285,7 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ - Recovery erfolgreich → Status `passwordRecovery` → `/reset-password`. - `linkInvalidOrExpired` → Fehlerzustand mit Aktion „Neuen Link anfordern“ (→ `/login` bzw. `/forgot-password`). -**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ → `/` (Formulierung passend zu F1). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 (j) hat gezeigt: Das Recovery-JWT trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach dem Bestätigungslink; eine Recovery-Sitzung ist daran nicht erkennbar. Verbindlich deshalb: +**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ (Formulierung passend zu F1) mit Knopf „Weiter“ → `/` (Guard-Ende, siehe 5.5 „Ergänzung 01c“). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 (j) hat gezeigt: Das Recovery-JWT trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach dem Bestätigungslink; eine Recovery-Sitzung ist daran nicht erkennbar. Verbindlich deshalb: - Der Guard `passwordRecovery` lebt **nur im laufenden Prozess** (Ereignis `passwordRecovery` aus `onAuthStateChange`). `authStatusProvider` leitet ihn **nicht** aus dem `amr`-Claim ab; eine Ableitung aus `amr = otp` würde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist unzulässig. - Nach einem Reload ist die Recovery-Sitzung eine normale Anmeldung. Das ist vertretbar, weil sie den Besitz des Postfachs nachweist, wie ein Magic Link (Risiko R12). Das alte Passwort bleibt dann gültig, bis der Nutzer es ändert (KTO-11). @@ -507,3 +508,10 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien 7. **Recovery-Guard (5.5):** Abweichung vom Wortlaut dokumentiert: `tokenRefreshed`/`signedIn` heben `passwordRecovery` nicht auf. 8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert). 9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab. + +### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris + +1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: GoTrue sendet `userUpdated`, bevor `updateUser` zurückkehrt; der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert. +2. **Neue Route `/forgot-password/check-email` (5.3):** Der Modus (Bestätigung/Reset) steht im Pfad statt im `extra`, damit er einen Web-Reload übersteht. Die Adresse wird im Modus Reset nicht übergeben. +3. **Kein Resend im Modus Reset** (wie 5.4): „Andere E-Mail-Adresse eingeben“ statt „E-Mail erneut senden“. `ResendOrigin.checkEmail` gilt weiter nur für den Modus Bestätigung. +4. **Erfolgstext Krit. 9** unverändert (kein „sofort“, ehrlich zu F1), dazu der Knopf „Weiter“; `AppBanner` um `actionEnabled` erweitert (Resend im LoginScreen bleibt beim Senden sichtbar, aber deaktiviert); `from` wird zwischen Login, Registrierung und „Passwort vergessen“ weitergegeben. -- 2.45.2 From 7c2f684098693d4bfca410f4a20c1ea272657ebd Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Tue, 3 Nov 2026 09:58:31 +0100 Subject: [PATCH 5/5] 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 --- app/lib/core/widgets/app_primary_button.dart | 6 ++ .../auth/data/auth_status_tracker.dart | 19 ++++- .../auth/data/supabase_auth_repository.dart | 14 ++-- .../features/auth/domain/auth_repository.dart | 5 +- .../auth/presentation/auth_failure_texts.dart | 41 +++++++++++ .../auth/presentation/auth_routes.dart | 6 +- .../check_email/check_email_screen.dart | 12 +++- .../forgot_password_screen.dart | 4 +- .../presentation/providers/auth_redirect.dart | 7 +- .../register/register_screen.dart | 32 ++++----- .../reset_password_controller.dart | 7 +- .../reset_password_controller.g.dart | 2 +- .../reset_password/reset_password_screen.dart | 39 +++++----- .../auth/data/auth_status_tracker_test.dart | 37 ++++++++++ .../data/supabase_auth_repository_test.dart | 72 +++++++++++++++++++ .../auth/presentation/auth_redirect_test.dart | 2 + .../auth/presentation/auth_router_test.dart | 40 ++++++++++- .../forgot_password_screen_test.dart | 27 +++++++ .../reset_password_controller_test.dart | 26 +++---- .../reset_password_screen_test.dart | 66 +++++++++++++---- docs/design/auth-screens.md | 8 +-- docs/specs/1-auth-registrierung-anmeldung.md | 8 +-- 22 files changed, 383 insertions(+), 97 deletions(-) diff --git a/app/lib/core/widgets/app_primary_button.dart b/app/lib/core/widgets/app_primary_button.dart index dce08a7..d8e140a 100644 --- a/app/lib/core/widgets/app_primary_button.dart +++ b/app/lib/core/widgets/app_primary_button.dart @@ -17,6 +17,7 @@ class AppPrimaryButton extends StatelessWidget { this.loadingLabel, this.icon, this.expand = true, + this.autofocus = false, }); final String label; @@ -26,6 +27,10 @@ class AppPrimaryButton extends StatelessWidget { final IconData? icon; final bool expand; + /// Takes the focus when it appears (e.g. after a view switch removed the + /// previously focused control). + final bool autofocus; + @override Widget build(BuildContext context) { final enabled = onPressed != null && !isLoading; @@ -43,6 +48,7 @@ class AppPrimaryButton extends StatelessWidget { : null; final button = FilledButton( + autofocus: autofocus, onPressed: enabled ? onPressed : null, child: Row( mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min, diff --git a/app/lib/features/auth/data/auth_status_tracker.dart b/app/lib/features/auth/data/auth_status_tracker.dart index cf2359c..2f2b068 100644 --- a/app/lib/features/auth/data/auth_status_tracker.dart +++ b/app/lib/features/auth/data/auth_status_tracker.dart @@ -59,9 +59,24 @@ class AuthStatusTracker { return statusFor(session); } - /// Ends the recovery after the user confirmed the success message. + /// 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) { - _end(); + if (_recovery && _passwordChanged) { + _end(); + } return statusFor(session); } diff --git a/app/lib/features/auth/data/supabase_auth_repository.dart b/app/lib/features/auth/data/supabase_auth_repository.dart index 0e81178..3808b30 100644 --- a/app/lib/features/auth/data/supabase_auth_repository.dart +++ b/app/lib/features/auth/data/supabase_auth_repository.dart @@ -123,14 +123,18 @@ class SupabaseAuthRepository implements AuthRepository { }); @override - Future> setNewPassword(String password) => _guard( - AuthOperation.updatePassword, - () => _auth.updateUser(UserAttributes(password: password)), - ); + Future> setNewPassword(String password) => + _guard(AuthOperation.updatePassword, () async { + await _auth.updateUser(UserAttributes(password: password)); + // gotrue delivers `userUpdated` asynchronously, possibly after this + // call returned. Mark the change here as well (idempotent), so the + // status never lags behind the successful result. + _publish(_tracker.markPasswordChanged(_auth.currentSession)); + }); @override void finishPasswordRecovery() { - if (_lastStatus is PasswordRecovery) { + if (_lastStatus case PasswordRecovery(passwordChanged: true)) { _publish(_tracker.finishRecovery(_auth.currentSession)); } } diff --git a/app/lib/features/auth/domain/auth_repository.dart b/app/lib/features/auth/domain/auth_repository.dart index d885981..8335e01 100644 --- a/app/lib/features/auth/domain/auth_repository.dart +++ b/app/lib/features/auth/domain/auth_repository.dart @@ -29,8 +29,9 @@ abstract interface class AuthRepository { Future> setNewPassword(String password); - /// Beendet den Recovery-Guard nach erfolgreich gesetztem Passwort (die - /// Sitzung bleibt bestehen). Ohne laufende Recovery ohne Wirkung. + /// Beendet den Recovery-Guard, nachdem das neue Passwort gesetzt ist (die + /// Sitzung bleibt bestehen). Ohne laufende Recovery oder vor dem + /// Passwortwechsel ohne Wirkung: Der Guard lässt sich nicht überspringen. void finishPasswordRecovery(); /// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur diff --git a/app/lib/features/auth/presentation/auth_failure_texts.dart b/app/lib/features/auth/presentation/auth_failure_texts.dart index 7379891..6e41bf8 100644 --- a/app/lib/features/auth/presentation/auth_failure_texts.dart +++ b/app/lib/features/auth/presentation/auth_failure_texts.dart @@ -1,5 +1,6 @@ import 'package:shuffle/core/l10n/l10n.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; /// User-facing text of an [AuthFailure] (Spec #1 § 4.4, texts from /// `docs/design/auth-screens.md`). @@ -41,3 +42,43 @@ String weakPasswordMessage( extension AuthFailureRetry on AuthFailure { bool get isRetryable => this is NetworkFailure || this is Unexpected; } + +/// Text of the password field error, shared by registration and reset. +/// +/// Order: server answer, `same_password`, "required", then the local policy +/// [error] (only passed when it should be shown). +String? passwordFieldError( + AppLocalizations l10n, { + required PasswordError? error, + required bool required, + List? serverReasons, + bool serverSamePassword = false, +}) { + if (serverReasons != null) { + return weakPasswordMessage(l10n, serverReasons); + } + if (serverSamePassword) { + return l10n.authErrorSamePassword; + } + if (required) { + return l10n.authPasswordRequired; + } + return switch (error) { + PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing), + PasswordCommon() => l10n.authPasswordCommon, + null => null, + }; +} + +/// The only message shown is the "n missing" count, which changes with every +/// keystroke: screens announce it only when the field is left. +bool isPasswordLengthHintOnly({ + required PasswordError? error, + required bool required, + List? serverReasons, + bool serverSamePassword = false, +}) => + serverReasons == null && + !serverSamePassword && + !required && + error is PasswordTooShort; diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart index 6b39748..14b53dc 100644 --- a/app/lib/features/auth/presentation/auth_routes.dart +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -41,8 +41,10 @@ final List authRoutes = [ GoRoute( path: 'check-email', // The mode is part of the path, so it survives a web reload. - builder: (context, state) => - const CheckEmailScreen(mode: CheckEmailMode.reset), + builder: (context, state) => CheckEmailScreen( + mode: CheckEmailMode.reset, + from: state.uri.queryParameters[AppRoutes.fromParam], + ), ), ], ), diff --git a/app/lib/features/auth/presentation/check_email/check_email_screen.dart b/app/lib/features/auth/presentation/check_email/check_email_screen.dart index be28d14..2c8b49f 100644 --- a/app/lib/features/auth/presentation/check_email/check_email_screen.dart +++ b/app/lib/features/auth/presentation/check_email/check_email_screen.dart @@ -12,6 +12,7 @@ import 'package:shuffle/core/widgets/app_text_field.dart'; import 'package:shuffle/features/auth/domain/resend_origin.dart'; import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart'; /// Which flow the "check your inbox" page belongs to. The mode comes from the @@ -35,10 +36,14 @@ class CheckEmailScreen extends ConsumerStatefulWidget { const CheckEmailScreen({ this.email, this.mode = CheckEmailMode.confirmation, + this.from, super.key, }); final String? email; + + /// Unchecked `from` query parameter, carried back to the sign-in page. + final String? from; final CheckEmailMode mode; @override @@ -138,7 +143,9 @@ class _CheckEmailScreenState extends ConsumerState { if (_isReset) TextButton( key: const Key('check_email_different_address'), - onPressed: () => context.go(AppRoutes.forgotPassword), + onPressed: () => context.go( + authLocation(AppRoutes.forgotPassword, from: widget.from), + ), child: Text(l10n.authCheckEmailDifferentAddress), ) else @@ -152,7 +159,8 @@ class _CheckEmailScreenState extends ConsumerState { const SizedBox(height: AppSpacing.sm), TextButton( key: const Key('check_email_back_to_login'), - onPressed: () => context.go(AppRoutes.login), + onPressed: () => + context.go(authLocation(AppRoutes.login, from: widget.from)), child: Text(l10n.authBackToLogin), ), ], diff --git a/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart b/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart index 72e85dc..7085735 100644 --- a/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart +++ b/app/lib/features/auth/presentation/forgot_password/forgot_password_screen.dart @@ -57,7 +57,9 @@ class _ForgotPasswordScreenState extends ConsumerState { if (!succeeded || !mounted) { return; } - context.go(AppRoutes.forgotPasswordCheckEmail); + context.go( + authLocation(AppRoutes.forgotPasswordCheckEmail, from: widget.from), + ); } @override diff --git a/app/lib/features/auth/presentation/providers/auth_redirect.dart b/app/lib/features/auth/presentation/providers/auth_redirect.dart index 9822131..81fae82 100644 --- a/app/lib/features/auth/presentation/providers/auth_redirect.dart +++ b/app/lib/features/auth/presentation/providers/auth_redirect.dart @@ -17,7 +17,12 @@ const _guestOnlyPaths = { }; /// Pfade, die nie als `from`-Ziel zugelassen sind (Schleifenschutz). -const _neverFromPaths = {..._guestOnlyPaths, AppRoutes.resetPassword}; +const _neverFromPaths = { + ..._guestOnlyPaths, + AppRoutes.resetPassword, + AppRoutes.registerCheckEmail, + AppRoutes.forgotPasswordCheckEmail, +}; /// Redirect-Regeln der Auth-Story (Spec #1 § 5.3), rein und ohne Zustand. /// diff --git a/app/lib/features/auth/presentation/register/register_screen.dart b/app/lib/features/auth/presentation/register/register_screen.dart index a9584bc..bd112a0 100644 --- a/app/lib/features/auth/presentation/register/register_screen.dart +++ b/app/lib/features/auth/presentation/register/register_screen.dart @@ -16,7 +16,6 @@ import 'package:shuffle/core/widgets/app_text_field.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; import 'package:shuffle/features/auth/domain/validation/email_format.dart'; -import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; @@ -281,26 +280,19 @@ class _RegisterScreenState extends ConsumerState { return null; } - /// The only password message shown is the "n missing" count (no server - /// answer, not "required"). - bool _isLengthHintOnly(RegisterState state) => - state.serverPasswordReasons == null && - !state.shownPasswordRequired && - state.shownPasswordError is PasswordTooShort; + bool _isLengthHintOnly(RegisterState state) => isPasswordLengthHintOnly( + error: state.shownPasswordError, + required: state.shownPasswordRequired, + serverReasons: state.serverPasswordReasons, + ); - String? _passwordError(AppLocalizations l10n, RegisterState state) { - if (state.serverPasswordReasons case final reasons?) { - return weakPasswordMessage(l10n, reasons); - } - if (state.shownPasswordRequired) { - return l10n.authPasswordRequired; - } - return switch (state.shownPasswordError) { - PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing), - PasswordCommon() => l10n.authPasswordCommon, - null => null, - }; - } + String? _passwordError(AppLocalizations l10n, RegisterState state) => + passwordFieldError( + l10n, + error: state.shownPasswordError, + required: state.shownPasswordRequired, + serverReasons: state.serverPasswordReasons, + ); String? _displayNameError(AppLocalizations l10n, RegisterState state) => switch (state.shownDisplayNameError) { diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart b/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart index b6b8372..9b0bc8e 100644 --- a/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart +++ b/app/lib/features/auth/presentation/reset_password/reset_password_controller.dart @@ -34,7 +34,7 @@ class ResetPasswordController extends _$ResetPasswordController { @override ResetPasswordState build() => const ResetPasswordState(); - void passwordChanged(String value) { + void onPasswordInput(String value) { // The password is checked while typing (Kriterium 3). state = state.copyWith( password: value, @@ -99,11 +99,14 @@ class ResetPasswordController extends _$ResetPasswordController { if (!ref.mounted) { return false; } - state = state.copyWith(status: ResetPasswordStatus.editing); final failure = result.errorOrNull; if (failure == null) { + // The form stays locked: the auth status moves to "password changed" + // and the screen switches to the success view. No window in which the + // form could be used again. return true; } + state = state.copyWith(status: ResetPasswordStatus.editing); switch (failure) { case WeakPassword(:final reasons): state = state.copyWith( diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart b/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart index 88bcfaa..2d22d85 100644 --- a/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart +++ b/app/lib/features/auth/presentation/reset_password/reset_password_controller.g.dart @@ -101,7 +101,7 @@ final class ResetPasswordControllerProvider } String _$resetPasswordControllerHash() => - r'ab273bab468ed730052be4877484943ad49f6e15'; + r'ba0b2b07e134ce80fd75c1b8d7c4f66034bb73c3'; /// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9). /// diff --git a/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart b/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart index 625e448..f479662 100644 --- a/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart +++ b/app/lib/features/auth/presentation/reset_password/reset_password_screen.dart @@ -11,7 +11,6 @@ import 'package:shuffle/core/widgets/app_banner.dart'; import 'package:shuffle/core/widgets/app_form_scaffold.dart'; import 'package:shuffle/core/widgets/app_password_field.dart'; import 'package:shuffle/core/widgets/app_primary_button.dart'; -import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; import 'package:shuffle/features/auth/presentation/reset_password/reset_password_controller.dart'; import 'package:shuffle/features/auth/presentation/reset_password/reset_password_state.dart'; @@ -108,6 +107,7 @@ class _ResetPasswordScreenState extends ConsumerState { const SizedBox(height: AppSpacing.xl), AppPrimaryButton( key: const Key('reset_password_continue'), + autofocus: true, label: l10n.authConfirmContinue, onPressed: _continue, ), @@ -156,7 +156,7 @@ class _ResetPasswordScreenState extends ConsumerState { if (!_typing) { setState(() => _typing = true); } - controller.passwordChanged(value); + controller.onPasswordInput(value); }, onSubmitted: (_) { if (!busy) { @@ -182,26 +182,19 @@ class _ResetPasswordScreenState extends ConsumerState { ]; } - bool _isLengthHintOnly(ResetPasswordState state) => - state.serverPasswordReasons == null && - !state.serverSamePassword && - !state.shownPasswordRequired && - state.shownPasswordError is PasswordTooShort; + bool _isLengthHintOnly(ResetPasswordState state) => isPasswordLengthHintOnly( + error: state.shownPasswordError, + required: state.shownPasswordRequired, + serverReasons: state.serverPasswordReasons, + serverSamePassword: state.serverSamePassword, + ); - String? _passwordError(AppLocalizations l10n, ResetPasswordState state) { - if (state.serverPasswordReasons case final reasons?) { - return weakPasswordMessage(l10n, reasons); - } - if (state.serverSamePassword) { - return l10n.authErrorSamePassword; - } - if (state.shownPasswordRequired) { - return l10n.authPasswordRequired; - } - return switch (state.shownPasswordError) { - PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing), - PasswordCommon() => l10n.authPasswordCommon, - null => null, - }; - } + String? _passwordError(AppLocalizations l10n, ResetPasswordState state) => + passwordFieldError( + l10n, + error: state.shownPasswordError, + required: state.shownPasswordRequired, + serverReasons: state.serverPasswordReasons, + serverSamePassword: state.serverSamePassword, + ); } diff --git a/app/test/features/auth/data/auth_status_tracker_test.dart b/app/test/features/auth/data/auth_status_tracker_test.dart index 4da5840..d030794 100644 --- a/app/test/features/auth/data/auth_status_tracker_test.dart +++ b/app/test/features/auth/data/auth_status_tracker_test.dart @@ -113,6 +113,43 @@ void main() { expect(tracker.statusFor(testSession()), isA()); }); + test('finishRecovery before the password change keeps the guard', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()); + final status = tracker.finishRecovery(testSession()); + expect(status, isA()); + expect((status as PasswordRecovery).passwordChanged, isFalse); + expect(tracker.statusFor(testSession()), isA()); + }); + + test('markPasswordChanged marks a running recovery (no event needed)', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.passwordRecovery, testSession()); + expect( + tracker.markPasswordChanged(testSession()), + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + ); + // A late userUpdated is idempotent. + expect( + tracker.handle(AuthChangeEvent.userUpdated, testSession()), + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + ); + }); + + test('markPasswordChanged without a recovery changes nothing', () { + final tracker = AuthStatusTracker() + ..handle(AuthChangeEvent.signedIn, testSession()); + expect(tracker.markPasswordChanged(testSession()), isA()); + }); + test('a new recovery resets the passwordChanged mark', () { final tracker = AuthStatusTracker() ..handle(AuthChangeEvent.passwordRecovery, testSession()) diff --git a/app/test/features/auth/data/supabase_auth_repository_test.dart b/app/test/features/auth/data/supabase_auth_repository_test.dart index 2496d6b..711683a 100644 --- a/app/test/features/auth/data/supabase_auth_repository_test.dart +++ b/app/test/features/auth/data/supabase_auth_repository_test.dart @@ -365,6 +365,78 @@ void main() { expect(repository.currentStatus, isA()); }); + test( + 'finishPasswordRecovery before the password change keeps the guard', + () { + currentSession = testSession(); + events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession())); + + repository.finishPasswordRecovery(); + + expect( + repository.currentStatus, + isA().having( + (s) => s.passwordChanged, + 'changed', + false, + ), + ); + }, + ); + + test( + 'setNewPassword marks the change even if userUpdated comes late', + () async { + currentSession = testSession(); + events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession())); + when( + () => auth.updateUser(any()), + ).thenAnswer((_) async => UserResponse.fromJson(testUser().toJson())); + + final result = await repository.setNewPassword('neues-passwort'); + + // No userUpdated event has arrived yet. + expect(result.isSuccess, isTrue); + expect( + repository.currentStatus, + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + ); + // The late event changes nothing. + events.add(AuthState(AuthChangeEvent.userUpdated, testSession())); + expect( + repository.currentStatus, + isA().having( + (s) => s.passwordChanged, + 'changed', + true, + ), + ); + }, + ); + + test('a failed setNewPassword does not mark the change', () async { + currentSession = testSession(); + events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession())); + when(() => auth.updateUser(any())).thenThrow( + const AuthApiException('x', statusCode: '422', code: 'same_password'), + ); + + await repository.setNewPassword('neues-passwort'); + + expect( + repository.currentStatus, + isA().having( + (s) => s.passwordChanged, + 'changed', + false, + ), + ); + }); + test('finishPasswordRecovery without a recovery changes nothing', () { currentSession = testSession(); events.add(AuthState(AuthChangeEvent.signedIn, testSession())); diff --git a/app/test/features/auth/presentation/auth_redirect_test.dart b/app/test/features/auth/presentation/auth_redirect_test.dart index 55d4e36..7d36132 100644 --- a/app/test/features/auth/presentation/auth_redirect_test.dart +++ b/app/test/features/auth/presentation/auth_redirect_test.dart @@ -76,6 +76,8 @@ void main() { 'private', '/login', '/reset-password', + '/register/check-email', + '/forgot-password/check-email', '/auth/confirm', ]) { expect( diff --git a/app/test/features/auth/presentation/auth_router_test.dart b/app/test/features/auth/presentation/auth_router_test.dart index df3da27..8d90539 100644 --- a/app/test/features/auth/presentation/auth_router_test.dart +++ b/app/test/features/auth/presentation/auth_router_test.dart @@ -222,7 +222,7 @@ void main() { }); group('KTO-01 Kriterium 8, 9: password reset flow through the router', () { - void stubGoTrue() { + void stubGoTrue({bool lateUserUpdated = false}) { registerFallbackValue(OtpType.recovery); registerFallbackValue(UserAttributes()); when( @@ -236,8 +236,17 @@ void main() { return AuthResponse(session: testSession()); }); when(() => mocks.auth.updateUser(any())).thenAnswer((_) async { - // gotrue emits userUpdated before updateUser returns. - emit(AuthChangeEvent.userUpdated, withSession: testSession()); + if (lateUserUpdated) { + // gotrue delivers the event asynchronously, after the return. + unawaited( + Future.microtask( + () => + emit(AuthChangeEvent.userUpdated, withSession: testSession()), + ), + ); + } else { + emit(AuthChangeEvent.userUpdated, withSession: testSession()); + } return UserResponse.fromJson(testUser().toJson()); }); when(() => mocks.auth.signOut()).thenAnswer((_) async { @@ -308,6 +317,31 @@ void main() { verifyNever(() => mocks.auth.signOut()); }); + testWidgets( + 'userUpdated after the return: same result, form never reusable', + (tester) async { + stubGoTrue(lateUserUpdated: true); + final (container: _, :router) = await start(tester); + await redeemRecoveryLink(tester, router); + + await savePassword(tester, 'ein-langes-passwort'); + + expect(currentPath(router), '/reset-password'); + expect( + find.byKey(const Key('reset_password_success_banner')), + findsOneWidget, + ); + expect(find.byKey(const Key('reset_password_submit')), findsNothing); + router.go('/'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/reset-password'); + + await tester.tap(find.byKey(const Key('reset_password_continue'))); + await tester.pumpAndSettle(); + expect(currentPath(router), '/'); + }, + ); + testWidgets('cancel signs out and leads to /login', (tester) async { stubGoTrue(); final (container: _, :router) = await start(tester); diff --git a/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart b/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart index 5ac7310..0fda660 100644 --- a/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart +++ b/app/test/features/auth/presentation/forgot_password/forgot_password_screen_test.dart @@ -210,6 +210,33 @@ void main() { expect(currentUri(router).toString(), '/login?from=%2Fprivate%3Fa%3D1'); }); + testWidgets('from survives the check-your-inbox page and comes back', ( + tester, + ) async { + final (:container, :router) = await open( + tester, + location: '/forgot-password?from=%2Fprivate', + ); + + await send(tester, 'anna@example.com'); + expect( + currentUri(router).toString(), + '/forgot-password/check-email?from=%2Fprivate', + ); + + await tester.tap(find.byKey(const Key('check_email_different_address'))); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/forgot-password?from=%2Fprivate'); + + await send(tester, 'anna@example.com'); + await tester.ensureVisible( + find.byKey(const Key('check_email_back_to_login')), + ); + await tester.tap(find.byKey(const Key('check_email_back_to_login'))); + await tester.pumpAndSettle(); + expect(currentUri(router).toString(), '/login?from=%2Fprivate'); + }); + testWidgets('an unsafe from is dropped', (tester) async { final (:container, :router) = await open( tester, diff --git a/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart b/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart index bbfb254..d947f30 100644 --- a/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart +++ b/app/test/features/auth/presentation/reset_password/reset_password_controller_test.dart @@ -44,7 +44,7 @@ void main() { group('KTO-01 Kriterium 3: password policy', () { test('too short is rejected locally and shows the missing count', () async { - notifier().passwordChanged('kurz'); + notifier().onPasswordInput('kurz'); expect(await notifier().submit(), isFalse); @@ -56,7 +56,7 @@ void main() { test('exactly 10 characters pass', () async { stubSet(ok()); - notifier().passwordChanged('1234567890'); + notifier().onPasswordInput('1234567890'); expect(await notifier().submit(), isTrue); @@ -66,7 +66,7 @@ void main() { test( 'a common password is rejected (the list loads before submit)', () async { - notifier().passwordChanged('Passwort1234'); + notifier().onPasswordInput('Passwort1234'); expect(await notifier().submit(), isFalse); @@ -83,13 +83,13 @@ void main() { test('the error shows while typing and after blur', () { expect(state().shownPasswordError, isNull); - notifier().passwordChanged('abc'); + notifier().onPasswordInput('abc'); expect(state().shownPasswordError, isNotNull); }); test('the password is sent unchanged (no trimming)', () async { stubSet(ok()); - notifier().passwordChanged(' zehn zeichen '); + notifier().onPasswordInput(' zehn zeichen '); await notifier().submit(); @@ -102,7 +102,7 @@ void main() { 'success returns true and keeps the form locked from re-submits', () async { stubSet(ok()); - notifier().passwordChanged('ein-langes-passwort'); + notifier().onPasswordInput('ein-langes-passwort'); expect(await notifier().submit(), isTrue); @@ -115,25 +115,25 @@ void main() { stubSet( failed(const AuthFailure.weakPassword([WeakPasswordReason.length])), ); - notifier().passwordChanged('ein-langes-passwort'); + notifier().onPasswordInput('ein-langes-passwort'); expect(await notifier().submit(), isFalse); expect(state().serverPasswordReasons, [WeakPasswordReason.length]); expect(state().bannerFailure, isNull); - notifier().passwordChanged('ein-anderes-passwort'); + notifier().onPasswordInput('ein-anderes-passwort'); expect(state().serverPasswordReasons, isNull); }); test('same_password is shown at the field', () async { stubSet(failed(const AuthFailure.samePassword())); - notifier().passwordChanged('ein-langes-passwort'); + notifier().onPasswordInput('ein-langes-passwort'); expect(await notifier().submit(), isFalse); expect(state().serverSamePassword, isTrue); expect(state().bannerFailure, isNull); - notifier().passwordChanged('ein-anderes-passwort'); + notifier().onPasswordInput('ein-anderes-passwort'); expect(state().serverSamePassword, isFalse); }); @@ -144,7 +144,7 @@ void main() { ]) { test('${failure.runtimeType} is a banner and keeps the input', () async { stubSet(failed(failure)); - notifier().passwordChanged('ein-langes-passwort'); + notifier().onPasswordInput('ein-langes-passwort'); expect(await notifier().submit(), isFalse); @@ -158,7 +158,7 @@ void main() { final completer = Completer>(); when(() => repository.setNewPassword(any())) .thenAnswer((_) => completer.future); - notifier().passwordChanged('ein-langes-passwort'); + notifier().onPasswordInput('ein-langes-passwort'); final first = notifier().submit(); expect(await notifier().submit(), isFalse); @@ -208,7 +208,7 @@ void main() { }); test('toString masks the password', () { - notifier().passwordChanged('geheimes-passwort'); + notifier().onPasswordInput('geheimes-passwort'); final text = state().toString(); diff --git a/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart b/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart index b185309..33374b3 100644 --- a/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart +++ b/app/test/features/auth/presentation/reset_password/reset_password_screen_test.dart @@ -33,12 +33,20 @@ void main() { setUpAll(registerAuthFallbacks); + /// What the real repository does on a successful `updateUser`: the status + /// moves to "password changed". + Future> succeed() async { + when(() => repository.currentStatus).thenReturn(_changed); + status.add(_changed); + return ok(); + } + setUp(() { repository = MockAuthRepository(); status = StreamController.broadcast(); when(() => repository.currentStatus).thenReturn(_recovery); when(() => repository.watchStatus()).thenAnswer((_) => status.stream); - when(() => repository.setNewPassword(any())).thenAnswer((_) async => ok()); + when(() => repository.setNewPassword(any())).thenAnswer((_) => succeed()); when(() => repository.signOut()).thenAnswer((_) async {}); }); @@ -85,12 +93,6 @@ void main() { await tester.pumpAndSettle(); } - /// What the real repository does on a successful `updateUser`. - void passwordChangedOnServer() { - when(() => repository.currentStatus).thenReturn(_changed); - status.add(_changed); - } - group('KTO-01 Kriterium 3: policy at the field', () { testWidgets('shows the rule hint and the missing count while typing', ( tester, @@ -171,8 +173,6 @@ void main() { await type(tester, 'ein-langes-passwort'); await save(tester); - passwordChangedOnServer(); - await tester.pumpAndSettle(); verify(() => repository.setNewPassword('ein-langes-passwort')).called(1); expect(find.text(_de.authPasswordChanged), findsOneWidget); @@ -207,6 +207,49 @@ void main() { expect(currentPath(router), '/'); }); + testWidgets('the form stays locked after success (no window to reuse it)', ( + tester, + ) async { + final completer = Completer>(); + when(() => repository.setNewPassword(any())) + .thenAnswer((_) => completer.future); + await open(tester); + await type(tester, 'ein-langes-passwort'); + await tester.tap(find.byKey(const Key('reset_password_submit'))); + await tester.pump(); + + // Success arrives, the status event is still on its way. + completer.complete(ok()); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester.widget(fieldOf('reset_password_password')).enabled, + isFalse, + ); + status.add(_changed); + when(() => repository.currentStatus).thenReturn(_changed); + await tester.pumpAndSettle(); + expect(find.text(_de.authPasswordChanged), findsOneWidget); + }); + + testWidgets('the success view puts the focus on "Weiter"', (tester) async { + await open(tester); + await type(tester, 'ein-langes-passwort'); + + await save(tester); + + final focused = FocusManager.instance.primaryFocus; + expect(focused, isNotNull); + expect( + find.ancestor( + of: find.byElementPredicate((e) => e == focused!.context), + matching: find.byKey(const Key('reset_password_continue')), + ), + findsOneWidget, + ); + }); + testWidgets('the success view survives a rebuilt screen', (tester) async { when(() => repository.currentStatus).thenReturn(_changed); @@ -246,7 +289,7 @@ void main() { .onPressed, isNull, ); - completer.complete(ok()); + completer.complete(await succeed()); await tester.pumpAndSettle(); verify(() => repository.setNewPassword(any())).called(1); }); @@ -269,8 +312,7 @@ void main() { .text, 'ein-langes-passwort', ); - when(() => repository.setNewPassword(any())) - .thenAnswer((_) async => ok()); + when(() => repository.setNewPassword(any())).thenAnswer((_) => succeed()); await tester.tap(find.text(_de.authActionRetry)); await tester.pumpAndSettle(); verify(() => repository.setNewPassword(any())).called(2); diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 1f1c867..75154b1 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -144,11 +144,11 @@ Passwort geändert. (Hinweis authPasswordChanged) Umsetzungsentscheidungen (01c): -- **Der Redirect nach `userUpdated` wäre ohne Eingriff ein Fehler:** GoTrue sendet `userUpdated`, bevor `updateUser` - zurückkehrt; die Regel „angemeldet auf `/reset-password` → `/`“ hätte die Seite entfernt, bevor die Erfolgsmeldung +- **Der Redirect nach `userUpdated` wäre ohne Eingriff ein Fehler:** gotrue liefert `userUpdated` asynchron (auch nach der Rückkehr von `updateUser`); die Regel „angemeldet auf `/reset-password` → `/`“ hätte die Seite entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Lösung: `userUpdated` während der Recovery beendet den Guard **nicht**, sondern setzt - `AuthStatus.passwordRecovery(passwordChanged: true)`. Erst „Weiter“ (`AuthRepository.finishPasswordRecovery()`) beendet den - Guard; der Router leitet dann nach `/`. Die Erfolgsansicht leitet sich aus dem Status ab (`passwordRecoveryCompleted`), + `AuthStatus.passwordRecovery(passwordChanged: true)`. Das Repository markiert die Änderung nach erfolgreichem `updateUser` zusätzlich selbst (idempotent), das Formular bleibt + bis zur Erfolgsansicht gesperrt. Erst „Weiter“ (`AuthRepository.finishPasswordRecovery()`, vor dem Wechsel ohne Wirkung) beendet den + Guard; der Fokus liegt beim Wechsel auf „Weiter“; der Router leitet dann nach `/`. Die Erfolgsansicht leitet sich aus dem Status ab (`passwordRecoveryCompleted`), überlebt also einen Neuaufbau der Seite. Bewusst **kein** zeitgesteuerter Sprung (Screenreader, Lesezeit). Spec § 5.5 ist entsprechend ergänzt. - „Abbrechen“ gibt es nur im Formular, nicht in der Erfolgsansicht (es würde eine gültige Sitzung abmelden). diff --git a/docs/specs/1-auth-registrierung-anmeldung.md b/docs/specs/1-auth-registrierung-anmeldung.md index 7082078..3c654f6 100644 --- a/docs/specs/1-auth-registrierung-anmeldung.md +++ b/docs/specs/1-auth-registrierung-anmeldung.md @@ -292,9 +292,9 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ ### 5.5 Provider / Notifier - `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy). -- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`. -- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12). -- **Ergänzung 01c (bewusst):** Auch `userUpdated` beendet den Guard nicht mehr. GoTrue sendet es, bevor `updateUser` zurückkehrt; der Redirect „angemeldet auf `/reset-password` → `/`“ hätte den Screen entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Der Tracker setzt stattdessen `AuthStatus.passwordRecovery(passwordChanged: true)`, der Router hält den Nutzer auf `/reset-password` (Erfolgsansicht mit Knopf „Weiter“), und erst `AuthRepository.finishPasswordRecovery()` beendet den Guard. `signedOut` (Abbrechen, Refresh-Fehler) beendet ihn weiterhin sofort. Der Zustand lebt weiter nur im Prozess (R12). +- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`; **Ausnahmen während einer laufenden Recovery siehe die beiden folgenden Punkte (Abweichung, Ergänzung 01c).** +- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `AuthRepository.finishPasswordRecovery()` (nach dem Passwortwechsel, siehe „Ergänzung 01c“), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12). +- **Ergänzung 01c (bewusst):** Auch `userUpdated` beendet den Guard nicht mehr. GoTrue sendet es asynchron, unabhängig vom Zeitpunkt der Rückkehr von `updateUser` (möglicherweise danach); ein Redirect „angemeldet auf `/reset-password` → `/`“ hätte den Screen entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Der Tracker setzt stattdessen `AuthStatus.passwordRecovery(passwordChanged: true)` (bei `userUpdated` **und** direkt nach erfolgreichem `setNewPassword`, idempotent; das Formular bleibt bis dahin gesperrt), der Router hält den Nutzer auf `/reset-password` (Erfolgsansicht mit Knopf „Weiter“), und erst `AuthRepository.finishPasswordRecovery()` beendet den Guard (ohne gesetzte `passwordChanged`-Markierung ohne Wirkung). `signedOut` (Abbrechen, Refresh-Fehler) beendet ihn weiterhin sofort. Der Zustand lebt weiter nur im Prozess (R12). - Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue` bzw. freezed-Formzustand. Keine Logik in Widgets. ### 5.6 Liste häufiger Passwörter (Krit. 3) @@ -511,7 +511,7 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien ### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris -1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: GoTrue sendet `userUpdated`, bevor `updateUser` zurückkehrt; der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert. +1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst. 2. **Neue Route `/forgot-password/check-email` (5.3):** Der Modus (Bestätigung/Reset) steht im Pfad statt im `extra`, damit er einen Web-Reload übersteht. Die Adresse wird im Modus Reset nicht übergeben. 3. **Kein Resend im Modus Reset** (wie 5.4): „Andere E-Mail-Adresse eingeben“ statt „E-Mail erneut senden“. `ResendOrigin.checkEmail` gilt weiter nur für den Modus Bestätigung. 4. **Erfolgstext Krit. 9** unverändert (kein „sofort“, ehrlich zu F1), dazu der Knopf „Weiter“; `AppBanner` um `actionEnabled` erweitert (Resend im LoginScreen bleibt beim Senden sichtbar, aber deaktiviert); `from` wird zwischen Login, Registrierung und „Passwort vergessen“ weitergegeben. -- 2.45.2