feat(auth): KTO-01 Passwort zurücksetzen (01c) (4/4) #36

Merged
cschlaefke merged 5 commits from feature/1-01c-passwort-reset into main 2026-11-03 09:21:44 +00:00
41 changed files with 3371 additions and 110 deletions
Showing only changes of commit 87c0d7d1ec - Show all commits

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

@ -6,12 +6,17 @@ import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
/// Bildet `AuthChangeEvent`s auf [AuthStatus] ab (Spec #1 § 5.5).
///
/// Abweichung von der Wortlaut-Zuordnung in § 5.5: Die Recovery bleibt
/// bestehen, bis `userUpdated` (Passwort geändert) oder `signedOut` eintrifft.
/// Ein `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard
/// nicht auf. Der Zustand lebt nur im Prozess (Spec #1 § 5.4, R12): Aus dem
/// `amr`-Claim lässt er sich nicht ableiten.
/// bestehen, bis [finishRecovery] oder `signedOut` eintrifft. Ein
/// `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard nicht
/// auf. `userUpdated` (Passwort geändert) beendet ihn ebenfalls **nicht**
/// mehr, sondern setzt nur `passwordChanged`: Sonst würde der Router den
/// Nutzer von `/reset-password` wegleiten, bevor die Erfolgsmeldung
/// (KTO-01 Kriterium 9) zu sehen ist. Erst die ausdrückliche Bestätigung des
/// Nutzers beendet den Guard. Der Zustand lebt nur im Prozess
/// (Spec #1 § 5.4, R12): Aus dem `amr`-Claim lässt er sich nicht ableiten.
class AuthStatusTracker {
bool _recovery = false;
bool _passwordChanged = false;
/// Status für [session] unter Berücksichtigung der laufenden Recovery.
AuthStatus statusFor(Session? session) {
@ -25,7 +30,10 @@ class AuthStatusTracker {
emailConfirmed: user.emailConfirmedAt != null,
);
return _recovery
? AuthStatus.passwordRecovery(authUser)
? AuthStatus.passwordRecovery(
authUser,
passwordChanged: _passwordChanged,
)
: AuthStatus.signedIn(authUser);
}
@ -34,14 +42,31 @@ class AuthStatusTracker {
switch (event) {
case AuthChangeEvent.passwordRecovery:
_recovery = session != null;
case AuthChangeEvent.signedOut || AuthChangeEvent.userUpdated:
_recovery = false;
_passwordChanged = false;
case AuthChangeEvent.userUpdated:
// The password change itself: stay in the guard (see class doc).
if (_recovery) {
_passwordChanged = true;
}
case AuthChangeEvent.signedOut:
_end();
default:
break;
}
if (session == null) {
_recovery = false;
_end();
}
return statusFor(session);
}
/// Ends the recovery after the user confirmed the success message.
AuthStatus finishRecovery(Session? session) {
_end();
return statusFor(session);
}
void _end() {
_recovery = false;
_passwordChanged = false;
}
}

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);
@ -126,6 +128,13 @@ class SupabaseAuthRepository implements AuthRepository {
() => _auth.updateUser(UserAttributes(password: password)),
);
@override
void finishPasswordRecovery() {
if (_lastStatus is PasswordRecovery) {
_publish(_tracker.finishRecovery(_auth.currentSession));
}
}
@override
Future<void> signOut() async {
try {

View file

@ -29,6 +29,10 @@ abstract interface class AuthRepository {
Future<Result<void, AuthFailure>> setNewPassword(String password);
/// Beendet den Recovery-Guard nach erfolgreich gesetztem Passwort (die
/// Sitzung bleibt bestehen). Ohne laufende Recovery ohne Wirkung.
void finishPasswordRecovery();
/// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur
/// geloggt.
Future<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,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,20 @@ 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) =>
const CheckEmailScreen(mode: CheckEmailMode.reset),
),
],
),
GoRoute(
path: AppRoutes.authConfirm,
builder: (context, state) => EmailLinkScreen(
@ -39,5 +53,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

@ -14,6 +14,17 @@ import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Which flow the "check your inbox" page belongs to. The mode comes from the
/// route (not from `extra`), so it survives a web reload.
enum CheckEmailMode {
/// After the registration: confirmation link, "resend" available.
confirmation,
/// After "forgot password": reset link, no resend (Spec #1 § 5.4). The user
/// can enter another address instead.
reset,
}
/// "Check your inbox" after the registration (`/register/check-email`,
/// KTO-01 Kriterium 1, 4 und 6).
///
@ -21,9 +32,14 @@ import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dar
/// account (Kriterium 4). [email] comes from the router `extra`; after a web
/// reload it is missing and the user types it in for "resend".
class CheckEmailScreen extends ConsumerStatefulWidget {
const CheckEmailScreen({this.email, super.key});
const CheckEmailScreen({
this.email,
this.mode = CheckEmailMode.confirmation,
super.key,
});
final String? email;
final CheckEmailMode mode;
@override
ConsumerState<CheckEmailScreen> createState() => _CheckEmailScreenState();
@ -32,6 +48,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 +80,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 +96,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 +105,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 +114,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,13 +135,20 @@ 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(AppRoutes.forgotPassword),
child: Text(l10n.authCheckEmailDifferentAddress),
)
else
AppPrimaryButton(
key: const Key('check_email_resend'),
label: l10n.authCheckEmailResend,
loadingLabel: l10n.authSubmitting,
isLoading: state.isSending,
onPressed: _resend,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
key: const Key('check_email_back_to_login'),

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,144 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_banner.dart';
import 'package:shuffle/core/widgets/app_form_scaffold.dart';
import 'package:shuffle/core/widgets/app_primary_button.dart';
import 'package:shuffle/core/widgets/app_text_field.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/forgot_password/forgot_password_controller.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Request a password reset link (`/forgot-password`, KTO-01 Kriterium 8).
/// Only shows state and forwards input to the [ForgotPasswordController].
/// [from] is carried back to the sign-in page.
class ForgotPasswordScreen extends ConsumerStatefulWidget {
const ForgotPasswordScreen({this.from, super.key});
final String? from;
@override
ConsumerState<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(AppRoutes.forgotPasswordCheckEmail);
}
@override
Widget build(BuildContext context) {
ref.listen(forgotPasswordControllerProvider, (previous, next) {
if (previous?.focusRequest != next.focusRequest) {
_emailFocus.requestFocus();
}
});
final state = ref.watch(forgotPasswordControllerProvider);
final controller = ref.read(forgotPasswordControllerProvider.notifier);
final l10n = context.l10n;
final busy = state.isSubmitting;
final failure = state.bannerFailure;
return AppFormScaffold(
key: const Key('forgot_password_screen'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AuthScreenTitle(l10n.authForgotTitle),
const SizedBox(height: AppSpacing.sm),
Text(
l10n.authForgotBody,
key: const Key('forgot_password_body'),
style: Theme.of(context).textTheme.bodyLarge,
),
if (failure != null) ...[
const SizedBox(height: AppSpacing.lg),
AppBanner(
key: const Key('forgot_password_banner'),
type: AppBannerType.error,
typeLabel: l10n.authBannerTypeError,
message: authFailureMessage(l10n, failure),
actionLabel: failure.isRetryable ? l10n.authActionRetry : null,
onAction: failure.isRetryable ? () => unawaited(_submit()) : null,
actionEnabled: !busy,
),
],
const SizedBox(height: AppSpacing.xl),
AppTextField(
key: const Key('forgot_password_email'),
label: l10n.authFieldEmailLabel,
controller: _email,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.email],
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
errorText: state.shownEmailError
? l10n.authErrorEmailInvalid
: null,
onChanged: controller.emailChanged,
onSubmitted: (_) {
if (!busy) {
unawaited(_submit());
}
},
),
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('forgot_password_submit'),
label: l10n.authForgotSubmit,
loadingLabel: l10n.authSubmitting,
isLoading: busy,
onPressed: _submit,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
key: const Key('forgot_password_back'),
onPressed: busy
? null
: () => context.go(
authLocation(AppRoutes.login, from: widget.from),
),
child: Text(l10n.authBackToLogin),
),
],
),
);
}
}

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

@ -96,6 +96,19 @@ String? safeFromPath(String? from) {
return from;
}
/// Pfad [path] mit dem geprüften `from`-Ziel als Query. Ein unsicheres oder
/// fehlendes [from] ergibt den Pfad ohne Query (Weitergabe zwischen den
/// Auth-Seiten, Prüfung wie beim Login).
String authLocation(String path, {String? from}) {
final safe = safeFromPath(from);
return safe == null
? path
: Uri(
path: path,
queryParameters: {AppRoutes.fromParam: safe},
).toString();
}
/// [RedirectSource] mit Anmeldestatus für den Router.
class AuthRedirectSource implements RedirectSource {
AuthRedirectSource({required this.readStatus, this.privatePaths = const []});

View file

@ -18,6 +18,7 @@ import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:shuffle/features/auth/presentation/register/register_controller.dart';
import 'package:shuffle/features/auth/presentation/register/register_state.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
@ -26,7 +27,10 @@ import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dar
/// Kriterium 1). Only shows state and forwards input to the
/// [RegisterController].
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
const RegisterScreen({this.from, super.key});
/// Unchecked `from` query parameter, carried back to the sign-in page.
final String? from;
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
@ -240,7 +244,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),
),
],

View file

@ -0,0 +1,135 @@
import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/reset_password/reset_password_state.dart';
part 'reset_password_controller.g.dart';
final _log = AppLogger('ResetPasswordController');
/// `true` as soon as the new password is set during a recovery. Derived from
/// the auth status, so the success view survives a rebuild of the screen.
@riverpod
bool passwordRecoveryCompleted(Ref ref) {
final status =
ref.watch(authStatusProvider).value ??
ref.watch(authRepositoryProvider).currentStatus;
return switch (status) {
PasswordRecovery(:final passwordChanged) => passwordChanged,
_ => false,
};
}
/// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9).
///
/// The page is only reachable in the recovery guard (router redirect).
@riverpod
class ResetPasswordController extends _$ResetPasswordController {
Future<void>? _commonPasswordsLoad;
@override
ResetPasswordState build() => const ResetPasswordState();
void passwordChanged(String value) {
// The password is checked while typing (Kriterium 3).
state = state.copyWith(
password: value,
passwordVisible: true,
serverPasswordReasons: null,
serverSamePassword: false,
);
unawaited(loadCommonPasswords());
}
/// Field left: show its error.
void passwordBlurred() {
state = state.copyWith(passwordVisible: true);
}
/// Starts loading the list of common passwords (lazy, once).
Future<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;
}
state = state.copyWith(status: ResetPasswordStatus.editing);
final failure = result.errorOrNull;
if (failure == null) {
return true;
}
switch (failure) {
case WeakPassword(:final reasons):
state = state.copyWith(
serverPasswordReasons: reasons,
focusRequest: state.focusRequest + 1,
);
case SamePassword():
state = state.copyWith(
serverSamePassword: true,
focusRequest: state.focusRequest + 1,
);
default:
state = state.copyWith(bannerFailure: failure);
}
return false;
}
/// "Continue" after the success message: ends the recovery guard (the
/// router then leaves `/reset-password`).
void finish() => ref.read(authRepositoryProvider).finishPasswordRecovery();
/// "Cancel": signs out, the recovery session is not kept.
Future<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'ab273bab468ed730052be4877484943ad49f6e15';
/// Logic of the "new password" form (KTO-01 Kriterium 3, 8 und 9).
///
/// The page is only reachable in the recovery guard (router redirect).
abstract class _$ResetPasswordController extends $Notifier<ResetPasswordState> {
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,207 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_banner.dart';
import 'package:shuffle/core/widgets/app_form_scaffold.dart';
import 'package:shuffle/core/widgets/app_password_field.dart';
import 'package:shuffle/core/widgets/app_primary_button.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart';
import 'package:shuffle/features/auth/presentation/reset_password/reset_password_controller.dart';
import 'package:shuffle/features/auth/presentation/reset_password/reset_password_state.dart';
import 'package:shuffle/features/auth/presentation/widgets/auth_screen_title.dart';
/// Sets the new password in the recovery guard (`/reset-password`,
/// KTO-01 Kriterium 3, 8 und 9). Only shows state and forwards input to the
/// [ResetPasswordController].
///
/// After the change the page stays and shows the success message (the
/// recovery guard holds the router here) until the user continues.
class ResetPasswordScreen extends ConsumerStatefulWidget {
const ResetPasswordScreen({super.key});
@override
ConsumerState<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'),
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.passwordChanged(value);
},
onSubmitted: (_) {
if (!busy) {
unawaited(_submit());
}
},
),
),
const SizedBox(height: AppSpacing.xl),
AppPrimaryButton(
key: const Key('reset_password_submit'),
label: l10n.authResetSubmit,
loadingLabel: l10n.authSubmitting,
isLoading: busy,
onPressed: _submit,
),
const SizedBox(height: AppSpacing.sm),
TextButton(
key: const Key('reset_password_cancel'),
onPressed: busy ? null : () => unawaited(controller.cancel()),
child: Text(l10n.authResetCancel),
),
];
}
bool _isLengthHintOnly(ResetPasswordState state) =>
state.serverPasswordReasons == null &&
!state.serverSamePassword &&
!state.shownPasswordRequired &&
state.shownPasswordError is PasswordTooShort;
String? _passwordError(AppLocalizations l10n, ResetPasswordState state) {
if (state.serverPasswordReasons case final reasons?) {
return weakPasswordMessage(l10n, reasons);
}
if (state.serverSamePassword) {
return l10n.authErrorSamePassword;
}
if (state.shownPasswordRequired) {
return l10n.authPasswordRequired;
}
return switch (state.shownPasswordError) {
PasswordTooShort(:final missing) => l10n.authPasswordTooShort(missing),
PasswordCommon() => l10n.authPasswordCommon,
null => null,
};
}
}

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. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet."
}

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. On other devices you will be signed out after about an hour at the latest."
}

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. On other devices you will be signed out after about an hour at the latest.'**
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. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet.';
}

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. On other devices you will be signed out after about an hour at the latest.';
}

View file

@ -82,9 +82,54 @@ 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.passwordRecovery, testSession())
..handle(AuthChangeEvent.userUpdated, testSession());
expect(tracker.finishRecovery(testSession()), isA<SignedIn>());
expect(tracker.statusFor(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,51 @@ 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 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', () {
@ -133,6 +144,26 @@ void main() {
});
});
group('authLocation (from forwarding)', () {
test('adds a safe from as query', () {
expect(
authLocation('/register', from: '/private?a=1'),
'/register?from=%2Fprivate%3Fa%3D1',
);
});
test('drops missing and unsafe values', () {
expect(authLocation('/register'), '/register');
for (final from in ['//evil.example', 'https://evil.example', '/login']) {
expect(
authLocation('/register', from: from),
'/register',
reason: from,
);
}
});
});
group('safeFromPath', () {
test('rejects auth pages with a trailing slash', () {
expect(safeFromPath('/login/'), isNull);

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,117 @@ void main() {
});
});
group('KTO-01 Kriterium 8, 9: password reset flow through the router', () {
void stubGoTrue() {
registerFallbackValue(OtpType.recovery);
registerFallbackValue(UserAttributes());
when(
() => mocks.auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async {
// gotrue emits passwordRecovery for a redeemed recovery link.
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
return AuthResponse(session: testSession());
});
when(() => mocks.auth.updateUser(any())).thenAnswer((_) async {
// gotrue emits userUpdated before updateUser returns.
emit(AuthChangeEvent.userUpdated, withSession: testSession());
return UserResponse.fromJson(testUser().toJson());
});
when(() => mocks.auth.signOut()).thenAnswer((_) async {
emit(AuthChangeEvent.signedOut);
});
}
Future<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('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

@ -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,282 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/l10n/app_localizations_de.dart';
import 'package:shuffle/l10n/app_localizations_en.dart';
import '../../../../helpers/router_helpers.dart';
import '../auth_ui_support.dart';
final _de = AppLocalizationsDe();
final _en = AppLocalizationsEn();
void main() {
late MockAuthRepository repository;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
when(() => repository.requestPasswordReset(any()))
.thenAnswer((_) async => ok());
});
Future<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('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().passwordChanged('kurz');
expect(await notifier().submit(), isFalse);
verifyNever(() => repository.setNewPassword(any()));
expect(state().shownPasswordError, isNotNull);
expect(state().focusRequest, 1);
expect(state().isSubmitting, isFalse);
});
test('exactly 10 characters pass', () async {
stubSet(ok());
notifier().passwordChanged('1234567890');
expect(await notifier().submit(), isTrue);
verify(() => repository.setNewPassword('1234567890')).called(1);
});
test(
'a common password is rejected (the list loads before submit)',
() async {
notifier().passwordChanged('Passwort1234');
expect(await notifier().submit(), isFalse);
verifyNever(() => repository.setNewPassword(any()));
expect(state().passwordError, isNotNull);
},
);
test('an empty password is "required" after a submit attempt', () async {
expect(await notifier().submit(), isFalse);
expect(state().shownPasswordRequired, isTrue);
});
test('the error shows while typing and after blur', () {
expect(state().shownPasswordError, isNull);
notifier().passwordChanged('abc');
expect(state().shownPasswordError, isNotNull);
});
test('the password is sent unchanged (no trimming)', () async {
stubSet(ok());
notifier().passwordChanged(' zehn zeichen ');
await notifier().submit();
verify(() => repository.setNewPassword(' zehn zeichen ')).called(1);
});
});
group('KTO-01 Kriterium 8, 9: set the new password', () {
test(
'success returns true and keeps the form locked from re-submits',
() async {
stubSet(ok());
notifier().passwordChanged('ein-langes-passwort');
expect(await notifier().submit(), isTrue);
expect(state().bannerFailure, isNull);
expect(state().serverPasswordReasons, isNull);
},
);
test('weak_password from the server is shown at the field', () async {
stubSet(
failed(const AuthFailure.weakPassword([WeakPasswordReason.length])),
);
notifier().passwordChanged('ein-langes-passwort');
expect(await notifier().submit(), isFalse);
expect(state().serverPasswordReasons, [WeakPasswordReason.length]);
expect(state().bannerFailure, isNull);
notifier().passwordChanged('ein-anderes-passwort');
expect(state().serverPasswordReasons, isNull);
});
test('same_password is shown at the field', () async {
stubSet(failed(const AuthFailure.samePassword()));
notifier().passwordChanged('ein-langes-passwort');
expect(await notifier().submit(), isFalse);
expect(state().serverSamePassword, isTrue);
expect(state().bannerFailure, isNull);
notifier().passwordChanged('ein-anderes-passwort');
expect(state().serverSamePassword, isFalse);
});
for (final failure in [
const AuthFailure.network(),
const AuthFailure.unexpected(),
const AuthFailure.rateLimited(),
]) {
test('${failure.runtimeType} is a banner and keeps the input', () async {
stubSet(failed(failure));
notifier().passwordChanged('ein-langes-passwort');
expect(await notifier().submit(), isFalse);
expect(state().bannerFailure, failure);
expect(state().password, 'ein-langes-passwort');
expect(state().isSubmitting, isFalse);
});
}
test('a second submit while one runs does nothing', () async {
final completer = Completer<Result<void, AuthFailure>>();
when(() => repository.setNewPassword(any()))
.thenAnswer((_) => completer.future);
notifier().passwordChanged('ein-langes-passwort');
final first = notifier().submit();
expect(await notifier().submit(), isFalse);
completer.complete(ok());
expect(await first, isTrue);
verify(() => repository.setNewPassword(any())).called(1);
});
});
group('KTO-01 Kriterium 8: finish and cancel', () {
test('finish ends the recovery guard', () {
notifier().finish();
verify(() => repository.finishPasswordRecovery()).called(1);
});
test('cancel signs out', () async {
when(() => repository.signOut()).thenAnswer((_) async {});
await notifier().cancel();
verify(() => repository.signOut()).called(1);
});
});
group('passwordRecoveryCompleted', () {
test('is true only for a recovery with the changed password', () async {
final events = StreamController<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().passwordChanged('geheimes-passwort');
final text = state().toString();
expect(text, isNot(contains('geheimes')));
expect(text, contains('<redacted>'));
});
}

View file

@ -0,0 +1,357 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
import 'package:shuffle/l10n/app_localizations_de.dart';
import 'package:shuffle/l10n/app_localizations_en.dart';
import '../../../../helpers/pump_app.dart';
import '../../../../helpers/router_helpers.dart';
import '../auth_ui_support.dart';
final _de = AppLocalizationsDe();
final _en = AppLocalizationsEn();
const _user = AuthUser(
id: 'u1',
email: 'anna@example.com',
emailConfirmed: true,
);
const _recovery = AuthStatus.passwordRecovery(_user);
const _changed = AuthStatus.passwordRecovery(_user, passwordChanged: true);
void main() {
late MockAuthRepository repository;
late StreamController<AuthStatus> status;
setUpAll(registerAuthFallbacks);
setUp(() {
repository = MockAuthRepository();
status = StreamController<AuthStatus>.broadcast();
when(() => repository.currentStatus).thenReturn(_recovery);
when(() => repository.watchStatus()).thenAnswer((_) => status.stream);
when(() => repository.setNewPassword(any())).thenAnswer((_) async => ok());
when(() => repository.signOut()).thenAnswer((_) async {});
});
tearDown(() => status.close());
Future<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();
}
/// What the real repository does on a successful `updateUser`.
void passwordChangedOnServer() {
when(() => repository.currentStatus).thenReturn(_changed);
status.add(_changed);
}
group('KTO-01 Kriterium 3: policy at the field', () {
testWidgets('shows the rule hint and the missing count while typing', (
tester,
) async {
await open(tester);
expect(find.text(_de.authPasswordRule), findsOneWidget);
await type(tester, 'kurz');
expect(find.text(_de.authPasswordTooShort(6)), findsOneWidget);
});
testWidgets('a common password is explained at the field', (tester) async {
await open(tester);
await type(tester, 'passwort1234');
await save(tester);
expect(find.text(_de.authPasswordCommon), findsOneWidget);
verifyNever(() => repository.setNewPassword(any()));
});
testWidgets('an empty submit says a password is required', (tester) async {
await open(tester);
await save(tester);
expect(find.text(_de.authPasswordRequired), findsOneWidget);
expect(
tester
.widget<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);
passwordChangedOnServer();
await tester.pumpAndSettle();
verify(() => repository.setNewPassword('ein-langes-passwort')).called(1);
expect(find.text(_de.authPasswordChanged), findsOneWidget);
expect(find.byKey(const Key('reset_password_password')), findsNothing);
// No cancel in the success view: it would sign out a valid session.
expect(find.byKey(const Key('reset_password_cancel')), findsNothing);
expect(currentPath(router), '/reset-password');
});
test('the hint is honest: no "immediately", mentions an hour', () {
for (final text in [_de.authPasswordChanged, _en.authPasswordChanged]) {
expect(text.toLowerCase(), isNot(contains('sofort')));
expect(text.toLowerCase(), isNot(contains('immediately')));
expect(text.toLowerCase(), isNot(contains('instantly')));
}
expect(_de.authPasswordChanged, contains('Stunde'));
expect(_en.authPasswordChanged, contains('hour'));
});
testWidgets('continue ends the guard and leaves for the start page', (
tester,
) async {
when(() => repository.currentStatus).thenReturn(_changed);
final (:container, :router) = await open(tester);
status.add(_changed);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('reset_password_continue')));
await tester.pumpAndSettle();
verify(() => repository.finishPasswordRecovery()).called(1);
expect(currentPath(router), '/');
});
testWidgets('the success view survives a rebuilt screen', (tester) async {
when(() => repository.currentStatus).thenReturn(_changed);
await open(tester);
expect(find.text(_de.authPasswordChanged), findsOneWidget);
});
testWidgets('cancel signs out', (tester) async {
await open(tester);
await tester.tap(find.byKey(const Key('reset_password_cancel')));
await tester.pumpAndSettle();
verify(() => repository.signOut()).called(1);
});
testWidgets('the button is locked while saving', (tester) async {
final completer = Completer<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(ok());
await tester.pumpAndSettle();
verify(() => repository.setNewPassword(any())).called(1);
});
testWidgets('offline shows the hint, keeps the input, can retry', (
tester,
) async {
when(() => repository.setNewPassword(any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
await open(tester);
await type(tester, 'ein-langes-passwort');
await save(tester);
expect(find.text(_de.authErrorNetwork), findsOneWidget);
expect(
tester
.widget<TextField>(fieldOf('reset_password_password'))
.controller!
.text,
'ein-langes-passwort',
);
when(() => repository.setNewPassword(any()))
.thenAnswer((_) async => ok());
await tester.tap(find.text(_de.authActionRetry));
await tester.pumpAndSettle();
verify(() => repository.setNewPassword(any())).called(2);
});
});
testWidgets('QUE-05 Kriterium 2: english texts', (tester) async {
await open(tester, locale: const Locale('en'));
expect(find.text(_en.authResetTitle), findsOneWidget);
expect(find.text(_en.authResetSubmit), findsOneWidget);
expect(find.text(_en.authResetCancel), findsOneWidget);
expect(find.text(_de.authResetTitle), findsNothing);
});
group('QUE-06 Kriterien 1-4: accessibility', () {
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
testWidgets('form meets the guidelines ($mode)', (tester) async {
final handle = tester.ensureSemantics();
when(() => repository.setNewPassword(any()))
.thenAnswer((_) async => failed(const AuthFailure.network()));
await open(tester, themeMode: mode);
await type(tester, 'ein-langes-passwort');
await save(tester);
expect(find.byKey(const Key('reset_password_banner')), findsOneWidget);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('success view meets the guidelines ($mode)', (tester) async {
final handle = tester.ensureSemantics();
when(() => repository.currentStatus).thenReturn(_changed);
await open(tester, themeMode: mode);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('title is a heading, success is announced as a live region', (
tester,
) async {
final handle = tester.ensureSemantics();
when(() => repository.currentStatus).thenReturn(_changed);
await open(tester);
expect(
tester.getSemantics(find.text(_de.authResetTitle).first),
matchesSemantics(label: _de.authResetTitle, isHeader: true),
);
expect(
find.bySemanticsLabel('Hinweis: ${_de.authPasswordChanged}'),
findsOneWidget,
);
handle.dispose();
});
testWidgets('text scale 2.0 on a small phone does not overflow', (
tester,
) async {
await open(tester, textScale: 2, size: const Size(320, 640));
await type(tester, 'kurz');
expect(tester.takeException(), isNull);
when(() => repository.currentStatus).thenReturn(_changed);
status.add(_changed);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('wide screens show a centered card', (tester) async {
await open(tester, size: const Size(1200, 900));
final card = tester.getRect(find.byType(Card).first);
expect(card.width, lessThanOrEqualTo(480));
expect(card.center.dx, closeTo(600, 1));
});
});
}

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 sendet `userUpdated`, bevor `updateUser`
zurückkehrt; die Regel „angemeldet auf `/reset-password` → `/`“ hätte die Seite entfernt, bevor die Erfolgsmeldung
(Krit. 9) sichtbar ist. Lösung: `userUpdated` während der Recovery beendet den Guard **nicht**, sondern setzt
`AuthStatus.passwordRecovery(passwordChanged: true)`. Erst „Weiter“ (`AuthRepository.finishPasswordRecovery()`) beendet den
Guard; der Router leitet dann nach `/`. Die Erfolgsansicht leitet sich aus dem Status ab (`passwordRecoveryCompleted`),
überlebt also einen Neuaufbau der Seite. Bewusst **kein** zeitgesteuerter Sprung (Screenreader, Lesezeit). Spec § 5.5
ist entsprechend ergänzt.
- „Abbrechen“ gibt es nur im Formular, nicht in der Erfolgsansicht (es würde eine gültige Sitzung abmelden).
- Passwortregel wie bei der Registrierung (10 Zeichen, Liste häufiger Passwörter; die Liste wird vor dem Absenden
geladen, Fallback nur Länge). `weak_password` und `same_password` stehen am Feld, alles andere im Banner.
- Hinweistext ehrlich zu F1: andere Geräte können bis zu etwa einer Stunde angemeldet bleiben; nichts von „sofort“.
- Keys: `reset_password_screen`, `reset_password_password`, `reset_password_submit`, `reset_password_cancel`,
`reset_password_banner`, `reset_password_success_banner`, `reset_password_continue`.
## 3. Textvorschläge
Ton: du-Form, kurz, sachlich-freundlich, keine Schuldzuweisung. Platzhalter in ICU-Syntax (`{n}`).
@ -217,7 +255,7 @@ teilt – Entscheidung beim flutter-dev (A2).
| `authResetTitle` | Neues Passwort festlegen | Set a new password |
| `authResetSubmit` | Passwort speichern | Save password |
| `authResetCancel` | Abbrechen | Cancel |
| `authPasswordChanged` | Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern. | Password changed. Other devices will be signed out; this can take up to an hour. |
| `authPasswordChanged` | Passwort geändert. Auf anderen Geräten wirst du spätestens nach etwa einer Stunde abgemeldet. | Password changed. On other devices you will be signed out after about an hour at the latest. |
## 4. Barrierefreiheit pro Screen (Checkliste für A2–A4 und T2)

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
```
@ -292,6 +293,7 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ
- `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy).
- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`.
- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12).
- **Ergänzung 01c (bewusst):** Auch `userUpdated` beendet den Guard nicht mehr. GoTrue sendet es, bevor `updateUser` zurückkehrt; der Redirect „angemeldet auf `/reset-password` → `/`“ hätte den Screen entfernt, bevor die Erfolgsmeldung (Krit. 9) sichtbar ist. Der Tracker setzt stattdessen `AuthStatus.passwordRecovery(passwordChanged: true)`, der Router hält den Nutzer auf `/reset-password` (Erfolgsansicht mit Knopf „Weiter“), und erst `AuthRepository.finishPasswordRecovery()` beendet den Guard. `signedOut` (Abbrechen, Refresh-Fehler) beendet ihn weiterhin sofort. Der Zustand lebt weiter nur im Prozess (R12).
- Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue<void>` bzw. freezed-Formzustand. Keine Logik in Widgets.
### 5.6 Liste häufiger Passwörter (Krit. 3)