feat(auth): add password reset (forgot password, recovery link, new password)
ForgotPasswordScreen with neutral reaction (F12), CheckEmail reset mode on its own path, recovery link handling and ResetPasswordScreen with the recovery guard held until the user confirms the success message. Carries the from parameter between login, register and forgot password. Removes the route placeholders. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
553a2b67cd
commit
87c0d7d1ec
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