feat(auth): KTO-01 Passwort zurücksetzen (01c) (4/4) #36
41 changed files with 3371 additions and 110 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
@ -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(),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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 []});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>'));
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>'));
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue