Compare commits

...

5 commits

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

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:58:31 +01:00
cschlaefke
3b2995463e docs(auth): document 01c deviations, reset mode route and AppBanner actionEnabled
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:50:42 +01:00
cschlaefke
fd4183c202 fix(auth): restore the reviewed success text for the password change
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:50:42 +01:00
cschlaefke
87c0d7d1ec feat(auth): add password reset (forgot password, recovery link, new password)
ForgotPasswordScreen with neutral reaction (F12), CheckEmail reset mode on
its own path, recovery link handling and ResetPasswordScreen with the
recovery guard held until the user confirms the success message. Carries
the from parameter between login, register and forgot password. Removes
the route placeholders.

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:47:03 +01:00
cschlaefke
553a2b67cd feat(core): let AppBanner disable its action instead of hiding it
Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 09:47:03 +01:00
47 changed files with 3737 additions and 141 deletions

View file

@ -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';

View file

@ -17,6 +17,7 @@ class AppBanner extends StatelessWidget {
super.key,
this.actionLabel,
this.onAction,
this.actionEnabled = true,
}) : assert(
(actionLabel == null) == (onAction == null),
'actionLabel and onAction belong together',
@ -28,6 +29,11 @@ class AppBanner extends StatelessWidget {
final String? actionLabel;
final VoidCallback? onAction;
/// `false` keeps the action visible and in the focus order but disabled
/// (e.g. while a request runs). Screen readers still find it and hear that
/// it is unavailable, instead of the control vanishing under their focus.
final bool actionEnabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@ -81,7 +87,7 @@ class AppBanner extends StatelessWidget {
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: onAction,
onPressed: actionEnabled ? onAction : null,
style: TextButton.styleFrom(foregroundColor: foreground),
child: Text(actionLabel!),
),

View file

@ -17,6 +17,7 @@ class AppPrimaryButton extends StatelessWidget {
this.loadingLabel,
this.icon,
this.expand = true,
this.autofocus = false,
});
final String label;
@ -26,6 +27,10 @@ class AppPrimaryButton extends StatelessWidget {
final IconData? icon;
final bool expand;
/// Takes the focus when it appears (e.g. after a view switch removed the
/// previously focused control).
final bool autofocus;
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !isLoading;
@ -43,6 +48,7 @@ class AppPrimaryButton extends StatelessWidget {
: null;
final button = FilledButton(
autofocus: autofocus,
onPressed: enabled ? onPressed : null,
child: Row(
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,

View file

@ -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,46 @@ 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);
}
/// The password was set during a running recovery (successful
/// `updateUser`). Idempotent to the later `userUpdated` event, which gotrue
/// delivers asynchronously, possibly after `updateUser` returned. Without a
/// running recovery nothing changes.
AuthStatus markPasswordChanged(Session? session) {
if (_recovery) {
_passwordChanged = true;
}
return statusFor(session);
}
/// Ends the recovery after the user confirmed the success message. Only
/// possible once the password is changed; before that the status stays
/// unchanged (the guard cannot be skipped).
AuthStatus finishRecovery(Session? session) {
if (_recovery && _passwordChanged) {
_end();
}
return statusFor(session);
}
void _end() {
_recovery = false;
_passwordChanged = false;
}
}

View file

@ -33,8 +33,10 @@ class SupabaseAuthRepository implements AuthRepository {
late AuthStatus _lastStatus;
StreamSubscription<AuthState>? _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);
@ -121,10 +123,21 @@ class SupabaseAuthRepository implements AuthRepository {
});
@override
Future<Result<void, AuthFailure>> setNewPassword(String password) => _guard(
AuthOperation.updatePassword,
() => _auth.updateUser(UserAttributes(password: password)),
);
Future<Result<void, AuthFailure>> setNewPassword(String password) =>
_guard(AuthOperation.updatePassword, () async {
await _auth.updateUser(UserAttributes(password: password));
// gotrue delivers `userUpdated` asynchronously, possibly after this
// call returned. Mark the change here as well (idempotent), so the
// status never lags behind the successful result.
_publish(_tracker.markPasswordChanged(_auth.currentSession));
});
@override
void finishPasswordRecovery() {
if (_lastStatus case PasswordRecovery(passwordChanged: true)) {
_publish(_tracker.finishRecovery(_auth.currentSession));
}
}
@override
Future<void> signOut() async {

View file

@ -29,6 +29,11 @@ abstract interface class AuthRepository {
Future<Result<void, AuthFailure>> setNewPassword(String password);
/// Beendet den Recovery-Guard, nachdem das neue Passwort gesetzt ist (die
/// Sitzung bleibt bestehen). Ohne laufende Recovery oder vor dem
/// Passwortwechsel ohne Wirkung: Der Guard lässt sich nicht überspringen.
void finishPasswordRecovery();
/// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur
/// geloggt.
Future<void> signOut();

View file

@ -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;

View file

@ -123,12 +123,12 @@ return passwordRecovery(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? signedOut,TResult Function( AuthUser user)? signedIn,TResult Function( AuthUser user)? passwordRecovery,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({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<TResult extends Object?>({required TResult Function() signedOut,required TResult Function( AuthUser user) signedIn,required TResult Function( AuthUser user) passwordRecovery,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({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 extends Object?>({TResult? Function()? signedOut,TResult? Function( AuthUser user)? signedIn,TResult? Function( AuthUser user)? passwordRecovery,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({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<PasswordRecovery> 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,
));
}

View file

@ -1,5 +1,6 @@
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
/// User-facing text of an [AuthFailure] (Spec #1 § 4.4, texts from
/// `docs/design/auth-screens.md`).
@ -41,3 +42,43 @@ String weakPasswordMessage(
extension AuthFailureRetry on AuthFailure {
bool get isRetryable => this is NetworkFailure || this is Unexpected;
}
/// Text of the password field error, shared by registration and reset.
///
/// Order: server answer, `same_password`, "required", then the local policy
/// [error] (only passed when it should be shown).
String? passwordFieldError(
AppLocalizations l10n, {
required PasswordError? error,
required bool required,
List<WeakPasswordReason>? serverReasons,
bool serverSamePassword = false,
}) {
if (serverReasons != null) {
return weakPasswordMessage(l10n, serverReasons);
}
if (serverSamePassword) {
return l10n.authErrorSamePassword;
}
if (required) {
return l10n.authPasswordRequired;
}
return switch (error) {
PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing),
PasswordCommon() => l10n.authPasswordCommon,
null => null,
};
}
/// The only message shown is the "n missing" count, which changes with every
/// keystroke: screens announce it only when the field is left.
bool isPasswordLengthHintOnly({
required PasswordError? error,
required bool required,
List<WeakPasswordReason>? serverReasons,
bool serverSamePassword = false,
}) =>
serverReasons == null &&
!serverSamePassword &&
!required &&
error is PasswordTooShort;

View file

@ -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),
);

View file

@ -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<RouteBase> authRoutes = [
GoRoute(
path: AppRoutes.login,
@ -16,7 +16,8 @@ final List<RouteBase> 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,22 @@ final List<RouteBase> 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) => CheckEmailScreen(
mode: CheckEmailMode.reset,
from: state.uri.queryParameters[AppRoutes.fromParam],
),
),
],
),
GoRoute(
path: AppRoutes.authConfirm,
builder: (context, state) => EmailLinkScreen(
@ -39,5 +55,8 @@ final List<RouteBase> authRoutes = [
type: state.uri.queryParameters['type'],
),
),
authPlaceholderRoute(AppRoutes.resetPassword, 'reset_password'),
GoRoute(
path: AppRoutes.resetPassword,
builder: (context, state) => const ResetPasswordScreen(),
),
];

View file

@ -12,8 +12,20 @@ import 'package:shuffle/core/widgets/app_text_field.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Which flow the "check your inbox" page belongs to. The mode comes from the
/// 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,10 +33,19 @@ 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,
this.from,
super.key,
});
final String? email;
/// Unchecked `from` query parameter, carried back to the sign-in page.
final String? from;
final CheckEmailMode mode;
@override
ConsumerState<CheckEmailScreen> createState() => _CheckEmailScreenState();
}
@ -32,6 +53,8 @@ class CheckEmailScreen extends ConsumerStatefulWidget {
class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
final _emailController = TextEditingController();
bool get _isReset => widget.mode == CheckEmailMode.reset;
bool get _hasKnownEmail => (widget.email ?? '').trim().isNotEmpty;
@override
@ -62,7 +85,7 @@ class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
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 +101,7 @@ class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
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 +110,7 @@ class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
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 +119,7 @@ class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
message: authFailureMessage(l10n, failure),
),
],
if (!_hasKnownEmail) ...[
if (!_isReset && !_hasKnownEmail) ...[
const SizedBox(height: AppSpacing.xl),
AppTextField(
key: const Key('check_email_email'),
@ -117,17 +140,27 @@ class _CheckEmailScreenState extends ConsumerState<CheckEmailScreen> {
),
],
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(
authLocation(AppRoutes.forgotPassword, from: widget.from),
),
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'),
onPressed: () => context.go(AppRoutes.login),
onPressed: () =>
context.go(authLocation(AppRoutes.login, from: widget.from)),
child: Text(l10n.authBackToLogin),
),
],

View file

@ -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<bool> 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;
}
}

View file

@ -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<ForgotPasswordController, ForgotPasswordState> {
/// 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<ForgotPasswordState>(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> {
ForgotPasswordState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<ForgotPasswordState, ForgotPasswordState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ForgotPasswordState, ForgotPasswordState>,
ForgotPasswordState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -0,0 +1,146 @@
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<ForgotPasswordScreen> createState() =>
_ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
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<void> _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(
authLocation(AppRoutes.forgotPasswordCheckEmail, from: widget.from),
);
}
@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),
),
],
),
);
}
}

View file

@ -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: <redacted>, status: $status, '
'bannerFailure: $bannerFailure)';
bool get isSubmitting => status == ForgotPasswordStatus.submitting;
EmailError? get emailError => EmailFormat.validate(email);
bool get shownEmailError =>
serverEmailInvalid || (emailVisible && emailError != null);
}

View file

@ -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>(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<ForgotPasswordState> get copyWith => _$ForgotPasswordStateCopyWithImpl<ForgotPasswordState>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View file

@ -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<LoginScreen> {
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<LoginScreen> {
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),
),
],

View file

@ -17,7 +17,12 @@ const _guestOnlyPaths = {
};
/// Pfade, die nie als `from`-Ziel zugelassen sind (Schleifenschutz).
const _neverFromPaths = {..._guestOnlyPaths, AppRoutes.resetPassword};
const _neverFromPaths = {
..._guestOnlyPaths,
AppRoutes.resetPassword,
AppRoutes.registerCheckEmail,
AppRoutes.forgotPasswordCheckEmail,
};
/// Redirect-Regeln der Auth-Story (Spec #1 § 5.3), rein und ohne Zustand.
///
@ -96,6 +101,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 []});

View file

@ -16,8 +16,8 @@ import 'package:shuffle/core/widgets/app_text_field.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:shuffle/features/auth/presentation/register/register_controller.dart';
import 'package:shuffle/features/auth/presentation/register/register_state.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
@ -26,7 +26,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<RegisterScreen> createState() => _RegisterScreenState();
@ -240,7 +243,11 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
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),
),
],
@ -273,26 +280,19 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
return null;
}
/// The only password message shown is the "n missing" count (no server
/// answer, not "required").
bool _isLengthHintOnly(RegisterState state) =>
state.serverPasswordReasons == null &&
!state.shownPasswordRequired &&
state.shownPasswordError is PasswordTooShort;
bool _isLengthHintOnly(RegisterState state) => isPasswordLengthHintOnly(
error: state.shownPasswordError,
required: state.shownPasswordRequired,
serverReasons: state.serverPasswordReasons,
);
String? _passwordError(AppLocalizations l10n, RegisterState state) {
if (state.serverPasswordReasons case final reasons?) {
return weakPasswordMessage(l10n, reasons);
}
if (state.shownPasswordRequired) {
return l10n.authPasswordRequired;
}
return switch (state.shownPasswordError) {
PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing),
PasswordCommon() => l10n.authPasswordCommon,
null => null,
};
}
String? _passwordError(AppLocalizations l10n, RegisterState state) =>
passwordFieldError(
l10n,
error: state.shownPasswordError,
required: state.shownPasswordRequired,
serverReasons: state.serverPasswordReasons,
);
String? _displayNameError(AppLocalizations l10n, RegisterState state) =>
switch (state.shownDisplayNameError) {

View file

@ -0,0 +1,138 @@
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<void>? _commonPasswordsLoad;
@override
ResetPasswordState build() => const ResetPasswordState();
void onPasswordInput(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<void> loadCommonPasswords() => _commonPasswordsLoad ??= _load();
Future<void> _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<bool> 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;
}
final failure = result.errorOrNull;
if (failure == null) {
// The form stays locked: the auth status moves to "password changed"
// and the screen switches to the success view. No window in which the
// form could be used again.
return true;
}
state = state.copyWith(status: ResetPasswordStatus.editing);
switch (failure) {
case WeakPassword(:final reasons):
state = state.copyWith(
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<void> cancel() async {
if (state.isSubmitting) {
return;
}
await ref.read(authRepositoryProvider).signOut();
}
}

View file

@ -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<bool, bool, bool>
with $Provider<bool> {
/// `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<bool> $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<bool>(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<ResetPasswordController, ResetPasswordState> {
/// 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<ResetPasswordState>(value),
);
}
}
String _$resetPasswordControllerHash() =>
r'ba0b2b07e134ce80fd75c1b8d7c4f66034bb73c3';
/// 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> {
ResetPasswordState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<ResetPasswordState, ResetPasswordState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<ResetPasswordState, ResetPasswordState>,
ResetPasswordState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -0,0 +1,200 @@
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/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<ResetPasswordScreen> createState() =>
_ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
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<void> _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'),
autofocus: true,
label: l10n.authConfirmContinue,
onPressed: _continue,
),
] else
..._form(context, state),
],
),
);
}
List<Widget> _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.onPasswordInput(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) => isPasswordLengthHintOnly(
error: state.shownPasswordError,
required: state.shownPasswordRequired,
serverReasons: state.serverPasswordReasons,
serverSamePassword: state.serverSamePassword,
);
String? _passwordError(AppLocalizations l10n, ResetPasswordState state) =>
passwordFieldError(
l10n,
error: state.shownPasswordError,
required: state.shownPasswordRequired,
serverReasons: state.serverPasswordReasons,
serverSamePassword: state.serverSamePassword,
);
}

View file

@ -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<WeakPasswordReason>? 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: <redacted>, 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;
}

View file

@ -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>(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<WeakPasswordReason>? 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<ResetPasswordState> get copyWith => _$ResetPasswordStateCopyWithImpl<ResetPasswordState>(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<WeakPasswordReason>? 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<WeakPasswordReason>?,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List<WeakPasswordReason>? 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 extends Object?>(TResult Function( String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List<WeakPasswordReason>? 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 extends Object?>(TResult? Function( String password, bool passwordVisible, ResetPasswordStatus status, CommonPasswordList? commonPasswords, List<WeakPasswordReason>? 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<WeakPasswordReason>? 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<WeakPasswordReason>? _serverPasswordReasons;
/// `weak_password` reasons reported by the server, shown at the field.
@override List<WeakPasswordReason>? 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<WeakPasswordReason>? 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<WeakPasswordReason>?,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

View file

@ -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. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern."
}

View file

@ -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. Other devices will be signed out; this can take up to an hour."
}

View file

@ -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. Other devices will be signed out; this can take up to an hour.'**
String get authPasswordChanged;
}
class _AppLocalizationsDelegate

View file

@ -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. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.';
}

View file

@ -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. Other devices will be signed out; this can take up to an hour.';
}

View file

@ -1,3 +1,5 @@
import 'dart:ui' show Tristate;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_banner.dart';
@ -46,6 +48,39 @@ void main() {
handle.dispose();
});
testWidgets('a disabled action stays visible and announced as disabled', (
tester,
) async {
final handle = tester.ensureSemantics();
var calls = 0;
await pumpWidgetUnderTest(
tester,
AppBanner(
type: AppBannerType.info,
typeLabel: 'Note',
message: 'Confirm first',
actionLabel: 'Resend',
onAction: () => calls++,
actionEnabled: false,
),
);
final action = find.widgetWithText(TextButton, 'Resend');
expect(action, findsOneWidget);
expect(tester.widget<TextButton>(action).onPressed, isNull);
expect(
tester
.getSemantics(action)
.getSemanticsData()
.flagsCollection
.isEnabled,
Tristate.isFalse,
);
await tester.tap(action, warnIfMissed: false);
expect(calls, 0);
handle.dispose();
});
testWidgets('action calls back and is at least 48 dp', (tester) async {
var calls = 0;
await pumpWidgetUnderTest(

View file

@ -82,9 +82,91 @@ void main() {
expect(tracker.statusFor(testSession()), isA<PasswordRecovery>());
});
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<PasswordRecovery>());
expect((status as PasswordRecovery).passwordChanged, isTrue);
// Still held after a token refresh.
expect(
tracker.handle(AuthChangeEvent.tokenRefreshed, testSession()),
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
true,
),
);
},
);
test('finishRecovery ends the guard', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession())
..handle(AuthChangeEvent.userUpdated, testSession());
expect(tracker.finishRecovery(testSession()), isA<SignedIn>());
expect(tracker.statusFor(testSession()), isA<SignedIn>());
});
test('finishRecovery before the password change keeps the guard', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
final status = tracker.finishRecovery(testSession());
expect(status, isA<PasswordRecovery>());
expect((status as PasswordRecovery).passwordChanged, isFalse);
expect(tracker.statusFor(testSession()), isA<PasswordRecovery>());
});
test('markPasswordChanged marks a running recovery (no event needed)', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
expect(
tracker.markPasswordChanged(testSession()),
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
true,
),
);
// A late userUpdated is idempotent.
expect(
tracker.handle(AuthChangeEvent.userUpdated, testSession()),
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
true,
),
);
});
test('markPasswordChanged without a recovery changes nothing', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.signedIn, testSession());
expect(tracker.markPasswordChanged(testSession()), isA<SignedIn>());
});
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<PasswordRecovery>().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<SignedIn>(),

View file

@ -329,15 +329,123 @@ void main() {
events.add(const AuthState(AuthChangeEvent.signedOut, null));
await Future<void>.delayed(Duration.zero);
expect(emitted.map((s) => s.runtimeType.toString()), [
'SignedOut',
'SignedIn',
'PasswordRecovery',
'SignedIn',
'SignedOut',
expect(emitted, [
const AuthStatus.signedOut(),
isA<SignedIn>(),
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
false,
),
isA<PasswordRecovery>().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<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
true,
),
);
repository.finishPasswordRecovery();
expect(repository.currentStatus, isA<SignedIn>());
});
test(
'finishPasswordRecovery before the password change keeps the guard',
() {
currentSession = testSession();
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
repository.finishPasswordRecovery();
expect(
repository.currentStatus,
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
false,
),
);
},
);
test(
'setNewPassword marks the change even if userUpdated comes late',
() async {
currentSession = testSession();
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
when(
() => auth.updateUser(any()),
).thenAnswer((_) async => UserResponse.fromJson(testUser().toJson()));
final result = await repository.setNewPassword('neues-passwort');
// No userUpdated event has arrived yet.
expect(result.isSuccess, isTrue);
expect(
repository.currentStatus,
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
true,
),
);
// The late event changes nothing.
events.add(AuthState(AuthChangeEvent.userUpdated, testSession()));
expect(
repository.currentStatus,
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
true,
),
);
},
);
test('a failed setNewPassword does not mark the change', () async {
currentSession = testSession();
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
when(() => auth.updateUser(any())).thenThrow(
const AuthApiException('x', statusCode: '422', code: 'same_password'),
);
await repository.setNewPassword('neues-passwort');
expect(
repository.currentStatus,
isA<PasswordRecovery>().having(
(s) => s.passwordChanged,
'changed',
false,
),
);
});
test('finishPasswordRecovery without a recovery changes nothing', () {
currentSession = testSession();
events.add(AuthState(AuthChangeEvent.signedIn, testSession()));
repository.finishPasswordRecovery();
expect(repository.currentStatus, isA<SignedIn>());
});
test('identical statuses (token refresh) are not re-emitted', () async {
final emitted = <AuthStatus>[];
final sub = repository.watchStatus().listen(emitted.add);

View file

@ -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', () {
@ -65,6 +76,8 @@ void main() {
'private',
'/login',
'/reset-password',
'/register/check-email',
'/forgot-password/check-email',
'/auth/confirm',
]) {
expect(
@ -133,6 +146,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);

View file

@ -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,151 @@ void main() {
});
});
group('KTO-01 Kriterium 8, 9: password reset flow through the router', () {
void stubGoTrue({bool lateUserUpdated = false}) {
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 {
if (lateUserUpdated) {
// gotrue delivers the event asynchronously, after the return.
unawaited(
Future.microtask(
() =>
emit(AuthChangeEvent.userUpdated, withSession: testSession()),
),
);
} else {
emit(AuthChangeEvent.userUpdated, withSession: testSession());
}
return UserResponse.fromJson(testUser().toJson());
});
when(() => mocks.auth.signOut()).thenAnswer((_) async {
emit(AuthChangeEvent.signedOut);
});
}
Future<void> 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<void> 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(
'userUpdated after the return: same result, form never reusable',
(tester) async {
stubGoTrue(lateUserUpdated: true);
final (container: _, :router) = await start(tester);
await redeemRecoveryLink(tester, router);
await savePassword(tester, 'ein-langes-passwort');
expect(currentPath(router), '/reset-password');
expect(
find.byKey(const Key('reset_password_success_banner')),
findsOneWidget,
);
expect(find.byKey(const Key('reset_password_submit')), findsNothing);
router.go('/');
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
await tester.tap(find.byKey(const Key('reset_password_continue')));
await tester.pumpAndSettle();
expect(currentPath(router), '/');
},
);
testWidgets('cancel signs out and leads to /login', (tester) async {
stubGoTrue();
final (container: _, :router) = await start(tester);
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,

View file

@ -78,8 +78,8 @@ void keepAlive(ProviderContainer container, ProviderListenable<Object?> p) {
typedef AuthUiHandle = ({ProviderContainer container, GoRouter router});
/// Pumps the real auth routes (register, check-email, confirm; the others are
/// placeholders) at [location] on a phone-sized screen.
/// Pumps the real auth routes (`authRoutes`, without router redirects) at
/// [location] on a phone-sized screen.
Future<AuthUiHandle> pumpAuthRoute(
WidgetTester tester,
MockAuthRepository repository, {

View file

@ -168,6 +168,97 @@ void main() {
});
});
group('KTO-01 Kriterium 8: reset mode', () {
Future<AuthUiHandle> 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);

View file

@ -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()),

View file

@ -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'));

View file

@ -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<void, AuthFailure> 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<Result<void, AuthFailure>>();
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('<redacted>'));
});
}

View file

@ -0,0 +1,309 @@
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<AuthUiHandle> 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<void> 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<Result<void, AuthFailure>>();
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<TextField>(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<TextField>(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<List<String>> pageFor(
Result<void, AuthFailure> 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('from survives the check-your-inbox page and comes back', (
tester,
) async {
final (:container, :router) = await open(
tester,
location: '/forgot-password?from=%2Fprivate',
);
await send(tester, 'anna@example.com');
expect(
currentUri(router).toString(),
'/forgot-password/check-email?from=%2Fprivate',
);
await tester.tap(find.byKey(const Key('check_email_different_address')));
await tester.pumpAndSettle();
expect(currentUri(router).toString(), '/forgot-password?from=%2Fprivate');
await send(tester, 'anna@example.com');
await tester.ensureVisible(
find.byKey(const Key('check_email_back_to_login')),
);
await tester.tap(find.byKey(const Key('check_email_back_to_login')));
await tester.pumpAndSettle();
expect(currentUri(router).toString(), '/login?from=%2Fprivate');
});
testWidgets('an unsafe from is dropped', (tester) async {
final (:container, :router) = await open(
tester,
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));
});
});
}

View file

@ -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<Result<void, AuthFailure>>();
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<TextButton>(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);

View file

@ -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<void, AuthFailure> 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().onPasswordInput('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().onPasswordInput('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().onPasswordInput('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().onPasswordInput('abc');
expect(state().shownPasswordError, isNotNull);
});
test('the password is sent unchanged (no trimming)', () async {
stubSet(ok());
notifier().onPasswordInput(' 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().onPasswordInput('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().onPasswordInput('ein-langes-passwort');
expect(await notifier().submit(), isFalse);
expect(state().serverPasswordReasons, [WeakPasswordReason.length]);
expect(state().bannerFailure, isNull);
notifier().onPasswordInput('ein-anderes-passwort');
expect(state().serverPasswordReasons, isNull);
});
test('same_password is shown at the field', () async {
stubSet(failed(const AuthFailure.samePassword()));
notifier().onPasswordInput('ein-langes-passwort');
expect(await notifier().submit(), isFalse);
expect(state().serverSamePassword, isTrue);
expect(state().bannerFailure, isNull);
notifier().onPasswordInput('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().onPasswordInput('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<Result<void, AuthFailure>>();
when(() => repository.setNewPassword(any()))
.thenAnswer((_) => completer.future);
notifier().onPasswordInput('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<AuthStatus>();
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<void>.delayed(Duration.zero);
expect(c.read(passwordRecoveryCompletedProvider), isTrue);
events.add(const AuthStatus.signedIn(_user));
await Future<void>.delayed(Duration.zero);
expect(c.read(passwordRecoveryCompletedProvider), isFalse);
});
});
test('toString masks the password', () {
notifier().onPasswordInput('geheimes-passwort');
final text = state().toString();
expect(text, isNot(contains('geheimes')));
expect(text, contains('<redacted>'));
});
}

View file

@ -0,0 +1,399 @@
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<AuthStatus> status;
setUpAll(registerAuthFallbacks);
/// What the real repository does on a successful `updateUser`: the status
/// moves to "password changed".
Future<Result<void, AuthFailure>> succeed() async {
when(() => repository.currentStatus).thenReturn(_changed);
status.add(_changed);
return ok();
}
setUp(() {
repository = MockAuthRepository();
status = StreamController<AuthStatus>.broadcast();
when(() => repository.currentStatus).thenReturn(_recovery);
when(() => repository.watchStatus()).thenAnswer((_) => status.stream);
when(() => repository.setNewPassword(any())).thenAnswer((_) => succeed());
when(() => repository.signOut()).thenAnswer((_) async {});
});
tearDown(() => status.close());
Future<AuthUiHandle> 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<void> 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<void> 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();
}
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<TextField>(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);
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 form stays locked after success (no window to reuse it)', (
tester,
) async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.setNewPassword(any()))
.thenAnswer((_) => completer.future);
await open(tester);
await type(tester, 'ein-langes-passwort');
await tester.tap(find.byKey(const Key('reset_password_submit')));
await tester.pump();
// Success arrives, the status event is still on its way.
completer.complete(ok());
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(
tester.widget<TextField>(fieldOf('reset_password_password')).enabled,
isFalse,
);
status.add(_changed);
when(() => repository.currentStatus).thenReturn(_changed);
await tester.pumpAndSettle();
expect(find.text(_de.authPasswordChanged), findsOneWidget);
});
testWidgets('the success view puts the focus on "Weiter"', (tester) async {
await open(tester);
await type(tester, 'ein-langes-passwort');
await save(tester);
final focused = FocusManager.instance.primaryFocus;
expect(focused, isNotNull);
expect(
find.ancestor(
of: find.byElementPredicate((e) => e == focused!.context),
matching: find.byKey(const Key('reset_password_continue')),
),
findsOneWidget,
);
});
testWidgets('the success view survives a rebuilt screen', (tester) async {
when(() => repository.currentStatus).thenReturn(_changed);
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<Result<void, AuthFailure>>();
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<TextField>(fieldOf('reset_password_password')).enabled,
isFalse,
);
expect(
tester
.widget<TextButton>(find.byKey(const Key('reset_password_cancel')))
.onPressed,
isNull,
);
completer.complete(await succeed());
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<TextField>(fieldOf('reset_password_password'))
.controller!
.text,
'ein-langes-passwort',
);
when(() => repository.setNewPassword(any())).thenAnswer((_) => succeed());
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));
});
});
}

View file

@ -76,7 +76,7 @@ getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vo
| `AppCheckboxField` | `AppCheckboxField(value, onChanged, label)` bzw. `.rich(segments: [AppLabelSegment.text(..), AppLabelSegment.link(text, onTap)])`, `errorText`. Ganze Zeile (Checkbox + Label) ist Touch-Ziel >= 48 dp. Links sind per Tastatur fokussierbar und für Screenreader als benutzerdefinierte Aktionen verfügbar; Semantics-Knoten meldet Zustand und den vollen Labeltext. |
| `AppPrimaryButton` | `label`, `onPressed`, `isLoading` (deaktiviert + Spinner, kein Doppelsenden), `loadingLabel` (gesprochener Text im Ladezustand), `icon`, `expand` (Standard: volle Breite). |
| `AppFormScaffold` | `child`, `appBar`, `maxWidth` (Standard `AppBreakpoints.authCardMaxWidth`). Kompakt: Vollbild, scrollbar; ab 600 dp zentrierte Karte. Tastatur schiebt den Inhalt hoch (Scaffold-Resize), Ziehen schließt die Tastatur. |
| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton). Live-Region mit „typeLabel: message“. |
| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton), `actionEnabled` (Standard `true`; `false` lässt die Aktion sichtbar, aber deaktiviert, z. B. während eine Anfrage läuft; Semantics melden sie als nicht aktiviert, der Screenreader-Fokus geht nicht verloren). Live-Region mit „typeLabel: message“. |
| `AppFieldError` | Interner Baustein: Fehlerzeile mit Icon als Live-Region, von Textfeld und Checkbox genutzt. |
Auth-Screens: Layout-Skizzen und Textvorschläge siehe `docs/design/auth-screens.md`.

View file

@ -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
<Zurück zur Anmeldung> (Modus Reset: <Andere E-Mail-Adresse eingeben> -> /forgot-password)
<Zurück zur Anmeldung> (Modus Reset: statt des Resend-Knopfs <Andere E-Mail-Adresse eingeben> -> /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 oder />)`. `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 )
<Zurück zur Anmeldung>
<Zurück zur Anmeldung> -> /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 )
<Abbrechen> -> signOut
Erfolg: Hinweis authPasswordChanged, dann Weiterleitung nach /
<Abbrechen> -> 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 liefert `userUpdated` asynchron (auch nach der Rückkehr von `updateUser`); die Regel „angemeldet auf `/reset-password` → `/`“ hätte die Seite entfernt, bevor die Erfolgsmeldung
(Krit. 9) sichtbar ist. Lösung: `userUpdated` während der Recovery beendet den Guard **nicht**, sondern setzt
`AuthStatus.passwordRecovery(passwordChanged: true)`. Das Repository markiert die Änderung nach erfolgreichem `updateUser` zusätzlich selbst (idempotent), das Formular bleibt
bis zur Erfolgsansicht gesperrt. Erst „Weiter“ (`AuthRepository.finishPasswordRecovery()`, vor dem Wechsel ohne Wirkung) beendet den
Guard; der Fokus liegt beim Wechsel auf „Weiter“; der Router leitet dann nach `/`. Die Erfolgsansicht leitet sich aus dem Status ab (`passwordRecoveryCompleted`),
überlebt also einen Neuaufbau der Seite. Bewusst **kein** zeitgesteuerter Sprung (Screenreader, Lesezeit). Spec § 5.5
ist entsprechend ergänzt.
- „Abbrechen“ gibt es nur im Formular, nicht in der Erfolgsansicht (es würde eine gültige Sitzung abmelden).
- 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}`).

View file

@ -224,6 +224,7 @@ Future<Result<void, AuthFailure>> signIn(String email, String password);
Future<Result<void, AuthFailure>> requestPasswordReset(String email);
Future<Result<void, AuthFailure>> verifyEmailLink(String tokenHash, EmailLinkType type); // confirmation | recovery
Future<Result<void, AuthFailure>> setNewPassword(String password);
void finishPasswordRecovery(); // beendet den Recovery-Guard nach dem Passwortwechsel (01c, siehe 5.5)
Future<void> signOut(); // genutzt bei abgebrochener Recovery; das UI zum Abmelden kommt mit KTO-11
```
@ -238,6 +239,7 @@ Future<void> signOut(); // genutzt bei abgebrochener Recovery; das UI zum Abmeld
| `/register` | RegisterScreen | nur abgemeldet |
| `/register/check-email` | CheckEmailScreen (E-Mail als `extra`, nicht in der URL) | öffentlich |
| `/forgot-password` | ForgotPasswordScreen → danach CheckEmailScreen (Modus Reset) | nur abgemeldet |
| `/forgot-password/check-email` | CheckEmailScreen im Modus Reset (Modus im Pfad, kein `extra`, übersteht einen Reload) | öffentlich |
| `/auth/confirm?token_hash&type` | EmailLinkScreen | öffentlich |
| `/reset-password` | ResetPasswordScreen | **nur** im Status `passwordRecovery` |
@ -283,15 +285,16 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ
- Recovery erfolgreich → Status `passwordRecovery` → `/reset-password`.
- `linkInvalidOrExpired` → Fehlerzustand mit Aktion „Neuen Link anfordern“ (→ `/login` bzw. `/forgot-password`).
**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ → `/` (Formulierung passend zu F1). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 (j) hat gezeigt: Das Recovery-JWT trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach dem Bestätigungslink; eine Recovery-Sitzung ist daran nicht erkennbar. Verbindlich deshalb:
**ResetPasswordScreen** (01c, Krit. 3, 8, 9): neues Passwort mit Live-Regeln aus `PasswordPolicy`, Server-Fehler `weak_password`/`same_password` am Feld. Erfolg → Hinweis „Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.“ (Formulierung passend zu F1) mit Knopf „Weiter“ → `/` (Guard-Ende, siehe 5.5 „Ergänzung 01c“). „Abbrechen“ → `signOut()`. Wird der Tab geschlossen oder die App neu geladen, stellt `supabase_flutter` die Sitzung als normale Anmeldung wieder her; das Ereignis `passwordRecovery` kommt dann nicht erneut. B0 (j) hat gezeigt: Das Recovery-JWT trägt `amr = [{"method":"otp"}]`, **identisch** zur Sitzung nach dem Bestätigungslink; eine Recovery-Sitzung ist daran nicht erkennbar. Verbindlich deshalb:
- Der Guard `passwordRecovery` lebt **nur im laufenden Prozess** (Ereignis `passwordRecovery` aus `onAuthStateChange`). `authStatusProvider` leitet ihn **nicht** aus dem `amr`-Claim ab; eine Ableitung aus `amr = otp` würde auch frisch bestätigte Konten in den Reset-Screen zwingen und ist unzulässig.
- Nach einem Reload ist die Recovery-Sitzung eine normale Anmeldung. Das ist vertretbar, weil sie den Besitz des Postfachs nachweist, wie ein Magic Link (Risiko R12). Das alte Passwort bleibt dann gültig, bis der Nutzer es ändert (KTO-11).
### 5.5 Provider / Notifier
- `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy).
- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`.
- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12).
- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`; **Ausnahmen während einer laufenden Recovery siehe die beiden folgenden Punkte (Abweichung, Ergänzung 01c).**
- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `AuthRepository.finishPasswordRecovery()` (nach dem Passwortwechsel, siehe „Ergänzung 01c“), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12).
- **Ergänzung 01c (bewusst):** Auch `userUpdated` beendet den Guard nicht mehr. GoTrue sendet es asynchron, unabhängig vom Zeitpunkt der Rückkehr von `updateUser` (möglicherweise danach); ein Redirect „angemeldet auf `/reset-password` → `/`“ hätte den Screen entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Der Tracker setzt stattdessen `AuthStatus.passwordRecovery(passwordChanged: true)` (bei `userUpdated` **und** direkt nach erfolgreichem `setNewPassword`, idempotent; das Formular bleibt bis dahin gesperrt), der Router hält den Nutzer auf `/reset-password` (Erfolgsansicht mit Knopf „Weiter“), und erst `AuthRepository.finishPasswordRecovery()` beendet den Guard (ohne gesetzte `passwordChanged`-Markierung ohne Wirkung). `signedOut` (Abbrechen, Refresh-Fehler) beendet ihn weiterhin sofort. Der Zustand lebt weiter nur im Prozess (R12).
- Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue<void>` bzw. freezed-Formzustand. Keine Logik in Widgets.
### 5.6 Liste häufiger Passwörter (Krit. 3)
@ -505,3 +508,10 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
7. **Recovery-Guard (5.5):** Abweichung vom Wortlaut dokumentiert: `tokenRefreshed`/`signedIn` heben `passwordRecovery` nicht auf.
8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert).
9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab.
### 2026-11-03 – Umsetzung 01c (Branch `feature/1-01c-passwort-reset`), Abweichungen zur Freigabe durch Chris
1. **Guard endet nicht mit `userUpdated` (5.5, 5.4):** `AuthStatus.passwordRecovery` bekommt `passwordChanged`; `AuthRepository.finishPasswordRecovery()` beendet den Guard, Knopf „Weiter“ statt automatischer Weiterleitung. Grund: Der Redirect hätte die Erfolgsmeldung (Krit. 9) verhindert; gotrue liefert `userUpdated` asynchron, deshalb markiert das Repository die Änderung zusätzlich selbst.
2. **Neue Route `/forgot-password/check-email` (5.3):** Der Modus (Bestätigung/Reset) steht im Pfad statt im `extra`, damit er einen Web-Reload übersteht. Die Adresse wird im Modus Reset nicht übergeben.
3. **Kein Resend im Modus Reset** (wie 5.4): „Andere E-Mail-Adresse eingeben“ statt „E-Mail erneut senden“. `ResendOrigin.checkEmail` gilt weiter nur für den Modus Bestätigung.
4. **Erfolgstext Krit. 9** unverändert (kein „sofort“, ehrlich zu F1), dazu der Knopf „Weiter“; `AppBanner` um `actionEnabled` erweitert (Resend im LoginScreen bleibt beim Senden sichtbar, aber deaktiviert); `from` wird zwischen Login, Registrierung und „Passwort vergessen“ weitergegeben.