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)