diff --git a/.claude/agents/docs-writer.md b/.claude/agents/docs-writer.md index b5ef57f..1b1df8f 100644 --- a/.claude/agents/docs-writer.md +++ b/.claude/agents/docs-writer.md @@ -1,7 +1,7 @@ --- name: docs-writer description: Technischer Redakteur. Hält die Projektdoku in Outline (Collection Shuffle) synchron mit Repo, Specs und ADRs, schreibt Changelog und Release Notes. Einsetzen nach gemergten Features, bei Releases und auf Wunsch. -tools: Read, Grep, Glob, Write, Edit, mcp__outline__*, mcp__forgejo__* +tools: Read, Grep, Glob, Write, Edit, mcp__outline__*, mcp__3f9ac702-fa59-4ed4-aa26-cece4fcf6d21__*, mcp__forgejo__* model: haiku --- diff --git a/app/lib/features/auth/presentation/auth_failure_texts.dart b/app/lib/features/auth/presentation/auth_failure_texts.dart index 5471f57..7379891 100644 --- a/app/lib/features/auth/presentation/auth_failure_texts.dart +++ b/app/lib/features/auth/presentation/auth_failure_texts.dart @@ -36,3 +36,8 @@ String weakPasswordMessage( }; return texts.isEmpty ? l10n.authErrorWeakPasswordCharacters : texts.join(' '); } + +/// Failures the user can fix by simply trying again (offline, unexpected). +extension AuthFailureRetry on AuthFailure { + bool get isRetryable => this is NetworkFailure || this is Unexpected; +} diff --git a/app/lib/features/auth/presentation/auth_routes.dart b/app/lib/features/auth/presentation/auth_routes.dart index bee11e0..f6fe2c7 100644 --- a/app/lib/features/auth/presentation/auth_routes.dart +++ b/app/lib/features/auth/presentation/auth_routes.dart @@ -3,12 +3,17 @@ 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/login/login_screen.dart'; import 'package:shuffle/features/auth/presentation/register/register_screen.dart'; -/// Routes of the auth pages (Spec #1 § 5.3). Login, forgot and reset password -/// are placeholders until 01b and 01c. +/// Routes of the auth pages (Spec #1 § 5.3). Forgot and reset password are +/// placeholders until 01c. final List authRoutes = [ - authPlaceholderRoute(AppRoutes.login, 'login'), + GoRoute( + path: AppRoutes.login, + builder: (context, state) => + LoginScreen(from: state.uri.queryParameters[AppRoutes.fromParam]), + ), GoRoute( path: AppRoutes.register, builder: (context, state) => const RegisterScreen(), diff --git a/app/lib/features/auth/presentation/login/login_controller.dart b/app/lib/features/auth/presentation/login/login_controller.dart new file mode 100644 index 0000000..1ec8b93 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_controller.dart @@ -0,0 +1,93 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/routing/app_routes.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/check_email/resend_controller.dart'; +import 'package:shuffle/features/auth/presentation/login/login_state.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; + +part 'login_controller.g.dart'; + +/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7). +@riverpod +class LoginController extends _$LoginController { + @override + LoginState build() => const LoginState(); + + void emailChanged(String value) { + state = state.copyWith(email: value); + } + + void passwordChanged(String value) { + state = state.copyWith(password: value); + } + + /// Field left: show its error. + void fieldBlurred(LoginField field) { + state = state.copyWith(visibleFields: {...state.visibleFields, field}); + } + + /// Validates and signs in. Returns the path to continue to on success + /// (the validated `from` target, otherwise the start page), `null` if the + /// user stays on the screen. A second call while one runs does nothing. + Future submit({String? from}) async { + if (state.isSubmitting) { + return null; + } + state = state.copyWith( + status: LoginStatus.submitting, + visibleFields: LoginField.values.toSet(), + bannerFailure: null, + unconfirmedEmail: null, + focusField: null, + ); + // A new attempt starts without the result of an earlier resend. + ref.invalidate(resendControllerProvider); + + final invalid = state.firstInvalidField; + if (invalid != null) { + state = state.copyWith( + status: LoginStatus.editing, + focusField: invalid, + focusRequest: state.focusRequest + 1, + ); + return null; + } + + final email = EmailFormat.normalize(state.email); + final result = await ref + .read(authRepositoryProvider) + .signIn(email, state.password); + final failure = result.errorOrNull; + if (failure == null) { + // Success: the target is returned even if the screen is already gone + // (the router redirect can remove it before this future completes), so + // the caller can still finish the autofill context. The status stays + // `submitting`, the form is not reused after a successful sign-in. + return safeFromPath(from) ?? AppRoutes.home; + } + if (!ref.mounted) { + return null; + } + state = state.copyWith( + status: LoginStatus.editing, + bannerFailure: failure, + unconfirmedEmail: failure is EmailNotConfirmed ? email : null, + ); + return null; + } + + /// "Resend confirmation email" after `emailNotConfirmed` (Kriterium 6). + /// The password was right, so a mail-limit answer is shown (F12). + Future resendConfirmation() async { + final email = state.unconfirmedEmail; + if (email == null) { + return; + } + await ref + .read(resendControllerProvider.notifier) + .resend(email, origin: ResendOrigin.login); + } +} diff --git a/app/lib/features/auth/presentation/login/login_controller.g.dart b/app/lib/features/auth/presentation/login/login_controller.g.dart new file mode 100644 index 0000000..17285c1 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_controller.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'login_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7). + +@ProviderFor(LoginController) +final loginControllerProvider = LoginControllerProvider._(); + +/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7). +final class LoginControllerProvider + extends $NotifierProvider { + /// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7). + LoginControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'loginControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$loginControllerHash(); + + @$internal + @override + LoginController create() => LoginController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(LoginState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$loginControllerHash() => r'693066c260db8abe8f70d04b88b216b2e0411579'; + +/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7). + +abstract class _$LoginController extends $Notifier { + LoginState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + LoginState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/auth/presentation/login/login_screen.dart b/app/lib/features/auth/presentation/login/login_screen.dart new file mode 100644 index 0000000..ef4189b --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_screen.dart @@ -0,0 +1,246 @@ +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/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/auth/domain/auth_failure.dart'; +import 'package:shuffle/features/auth/presentation/auth_failure_texts.dart'; +import 'package:shuffle/features/auth/presentation/check_email/resend_controller.dart'; +import 'package:shuffle/features/auth/presentation/login/login_controller.dart'; +import 'package:shuffle/features/auth/presentation/login/login_state.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 +/// forwards input to the [LoginController]. [from] is the unchecked `from` +/// query parameter; the controller validates it. +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({this.from, super.key}); + + final String? from; + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _emailFocus = FocusNode(); + final _passwordFocus = FocusNode(); + + @override + void initState() { + super.initState(); + _emailFocus.addListener( + () => _onFocusChange(_emailFocus, LoginField.email), + ); + _passwordFocus.addListener( + () => _onFocusChange(_passwordFocus, LoginField.password), + ); + } + + void _onFocusChange(FocusNode node, LoginField field) { + if (!node.hasFocus) { + ref.read(loginControllerProvider.notifier).fieldBlurred(field); + } + } + + @override + void dispose() { + _email.dispose(); + _password.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + Future _submit() async { + final target = await ref + .read(loginControllerProvider.notifier) + .submit(from: widget.from); + if (target == null) { + return; + } + // Also when the router redirect (status signedIn) already removed this + // screen: the password manager must still learn that sign-in worked. + TextInput.finishAutofillContext(); + if (!mounted) { + return; + } + context.go(target); + } + + void _requestFocus(LoginField? field) { + switch (field) { + case LoginField.email: + _emailFocus.requestFocus(); + case LoginField.password: + _passwordFocus.requestFocus(); + case null: + break; + } + } + + @override + Widget build(BuildContext context) { + ref.listen(loginControllerProvider, (previous, next) { + if (previous?.focusRequest != next.focusRequest) { + _requestFocus(next.focusField); + } + }); + final state = ref.watch(loginControllerProvider); + final resend = ref.watch(resendControllerProvider); + final controller = ref.read(loginControllerProvider.notifier); + final l10n = context.l10n; + final busy = state.isSubmitting; + + return AppFormScaffold( + key: const Key('login_screen'), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AuthScreenTitle(l10n.authLoginTitle), + if (state.bannerFailure case final failure?) ...[ + const SizedBox(height: AppSpacing.lg), + if (state.showsNotConfirmed) + AppBanner( + key: const Key('login_not_confirmed_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: l10n.authErrorEmailNotConfirmed, + actionLabel: resend.isSending + ? null + : l10n.authActionResendConfirmation, + onAction: resend.isSending + ? null + : () => unawaited(controller.resendConfirmation()), + ) + else + _FailureBanner( + key: const Key('login_banner'), + failure: failure, + onRetry: busy ? null : _submit, + ), + ], + if (state.showsNotConfirmed && resend.sent) ...[ + const SizedBox(height: AppSpacing.md), + AppBanner( + key: const Key('login_resent_banner'), + type: AppBannerType.info, + typeLabel: l10n.authBannerTypeInfo, + message: l10n.authCheckEmailResent, + ), + ], + if ((state.showsNotConfirmed, resend.failure) case ( + true, + final resendFailure?, + )) ...[ + const SizedBox(height: AppSpacing.md), + AppBanner( + key: const Key('login_resend_error_banner'), + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, resendFailure), + ), + ], + const SizedBox(height: AppSpacing.xl), + AppTextField( + key: const Key('login_email'), + label: l10n.authFieldEmailLabel, + controller: _email, + focusNode: _emailFocus, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + autofillHints: const [ + AutofillHints.username, + AutofillHints.email, + ], + autocorrect: false, + enableSuggestions: false, + enabled: !busy, + errorText: state.shownEmailError + ? l10n.authErrorEmailInvalid + : null, + onChanged: controller.emailChanged, + ), + const SizedBox(height: AppSpacing.lg), + AppPasswordField( + key: const Key('login_password'), + label: l10n.authFieldPasswordLabel, + showPasswordLabel: l10n.authPasswordShow, + hidePasswordLabel: l10n.authPasswordHide, + controller: _password, + focusNode: _passwordFocus, + textInputAction: TextInputAction.done, + enabled: !busy, + errorText: state.shownPasswordRequired + ? l10n.authPasswordRequired + : null, + onChanged: controller.passwordChanged, + onSubmitted: (_) { + if (!busy) { + unawaited(_submit()); + } + }, + ), + const SizedBox(height: AppSpacing.xl), + AppPrimaryButton( + key: const Key('login_submit'), + label: l10n.authLoginSubmit, + loadingLabel: l10n.authSubmitting, + isLoading: busy, + onPressed: _submit, + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + key: const Key('login_forgot_link'), + onPressed: busy + ? null + : () => context.go(AppRoutes.forgotPassword), + child: Text(l10n.authLoginForgotPassword), + ), + TextButton( + key: const Key('login_register_link'), + onPressed: busy ? null : () => context.go(AppRoutes.register), + child: Text(l10n.authLoginCreateAccount), + ), + ], + ), + ), + ); + } +} + +class _FailureBanner extends StatelessWidget { + const _FailureBanner({ + required this.failure, + required this.onRetry, + super.key, + }); + + final AuthFailure failure; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final retryable = failure.isRetryable; + return AppBanner( + type: AppBannerType.error, + typeLabel: l10n.authBannerTypeError, + message: authFailureMessage(l10n, failure), + actionLabel: retryable && onRetry != null ? l10n.authActionRetry : null, + onAction: retryable ? onRetry : null, + ); + } +} diff --git a/app/lib/features/auth/presentation/login/login_state.dart b/app/lib/features/auth/presentation/login/login_state.dart new file mode 100644 index 0000000..3a83202 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_state.dart @@ -0,0 +1,75 @@ +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 'login_state.freezed.dart'; + +/// Form fields of the sign-in, in reading order. +enum LoginField { email, password } + +enum LoginStatus { editing, submitting } + +/// Values, visibility of field errors and submit state of the sign-in form. +/// +/// Credential failures are never attached to a field (Kriterium 7): they +/// show as [bannerFailure] above the form. +@freezed +abstract class LoginState with _$LoginState { + const factory LoginState({ + @Default('') String email, + @Default('') String password, + @Default({}) Set visibleFields, + @Default(LoginStatus.editing) LoginStatus status, + + /// Failure of the last attempt, shown as banner. + AuthFailure? bannerFailure, + + /// Address of the last attempt that ended in `emailNotConfirmed`. The + /// resend action uses this one, not whatever is in the field now. + String? unconfirmedEmail, + + /// Field that should receive focus after a failed submit. + LoginField? focusField, + + /// Incremented whenever [focusField] is set. + @Default(0) int focusRequest, + }) = _LoginState; + + const LoginState._(); + + /// No credentials in logs or error reports. + @override + String toString() => + 'LoginState(email: , password: , status: $status, ' + 'bannerFailure: $bannerFailure)'; + + bool get isSubmitting => status == LoginStatus.submitting; + + EmailError? get emailError => EmailFormat.validate(email); + + bool get passwordMissing => password.isEmpty; + + bool hasError(LoginField field) => switch (field) { + LoginField.email => emailError != null, + LoginField.password => passwordMissing, + }; + + LoginField? get firstInvalidField { + for (final field in LoginField.values) { + if (hasError(field)) { + return field; + } + } + return null; + } + + bool isVisible(LoginField field) => visibleFields.contains(field); + + bool get shownEmailError => isVisible(LoginField.email) && emailError != null; + + bool get shownPasswordRequired => + isVisible(LoginField.password) && passwordMissing; + + bool get showsNotConfirmed => + bannerFailure is EmailNotConfirmed && unconfirmedEmail != null; +} diff --git a/app/lib/features/auth/presentation/login/login_state.freezed.dart b/app/lib/features/auth/presentation/login/login_state.freezed.dart new file mode 100644 index 0000000..3376d37 --- /dev/null +++ b/app/lib/features/auth/presentation/login/login_state.freezed.dart @@ -0,0 +1,331 @@ +// 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 'login_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LoginState { + + String get email; String get password; Set get visibleFields; LoginStatus get status;/// Failure of the last attempt, shown as banner. + AuthFailure? get bannerFailure;/// Address of the last attempt that ended in `emailNotConfirmed`. The +/// resend action uses this one, not whatever is in the field now. + String? get unconfirmedEmail;/// Field that should receive focus after a failed submit. + LoginField? get focusField;/// Incremented whenever [focusField] is set. + int get focusRequest; +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LoginStateCopyWith get copyWith => _$LoginStateCopyWithImpl(this as LoginState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as LoginState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is LoginState&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&const DeepCollectionEquality().equals(other.visibleFields, _this.visibleFields)&&(identical(other.status, _this.status) || other.status == _this.status)&&(identical(other.bannerFailure, _this.bannerFailure) || other.bannerFailure == _this.bannerFailure)&&(identical(other.unconfirmedEmail, _this.unconfirmedEmail) || other.unconfirmedEmail == _this.unconfirmedEmail)&&(identical(other.focusField, _this.focusField) || other.focusField == _this.focusField)&&(identical(other.focusRequest, _this.focusRequest) || other.focusRequest == _this.focusRequest)); +} + + +@override +int get hashCode { + final _this = this as LoginState; + return Object.hash(runtimeType,_this.email,_this.password,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.bannerFailure,_this.unconfirmedEmail,_this.focusField,_this.focusRequest); +} + + + +} + +/// @nodoc +abstract mixin class $LoginStateCopyWith<$Res> { + factory $LoginStateCopyWith(LoginState value, $Res Function(LoginState) _then) = _$LoginStateCopyWithImpl; +@useResult +$Res call({ + String email, String password, Set visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest +}); + + +$AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class _$LoginStateCopyWithImpl<$Res> + implements $LoginStateCopyWith<$Res> { + _$LoginStateCopyWithImpl(this._self, this._then); + + final LoginState _self; + final $Res Function(LoginState) _then; + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? email = null,Object? password = null,Object? visibleFields = null,Object? status = null,Object? bannerFailure = freezed,Object? unconfirmedEmail = freezed,Object? focusField = freezed,Object? focusRequest = null,}) { + return _then(LoginState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,visibleFields: null == visibleFields ? _self.visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable +as Set,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LoginStatus,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,unconfirmedEmail: freezed == unconfirmedEmail ? _self.unconfirmedEmail : unconfirmedEmail // ignore: cast_nullable_to_non_nullable +as String?,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable +as LoginField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of LoginState +/// 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 [LoginState]. +extension LoginStatePatterns on LoginState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LoginState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LoginState value) $default,){ +final _that = this; +switch (_that) { +case _LoginState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LoginState value)? $default,){ +final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String email, String password, Set visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that.email,_that.password,_that.visibleFields,_that.status,_that.bannerFailure,_that.unconfirmedEmail,_that.focusField,_that.focusRequest);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String email, String password, Set visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest) $default,) {final _that = this; +switch (_that) { +case _LoginState(): +return $default(_that.email,_that.password,_that.visibleFields,_that.status,_that.bannerFailure,_that.unconfirmedEmail,_that.focusField,_that.focusRequest);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String email, String password, Set visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest)? $default,) {final _that = this; +switch (_that) { +case _LoginState() when $default != null: +return $default(_that.email,_that.password,_that.visibleFields,_that.status,_that.bannerFailure,_that.unconfirmedEmail,_that.focusField,_that.focusRequest);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LoginState extends LoginState { + const _LoginState({this.email = '', this.password = '', Set visibleFields = const {}, this.status = LoginStatus.editing, this.bannerFailure, this.unconfirmedEmail, this.focusField, this.focusRequest = 0}): _visibleFields = visibleFields,super._(); + + +@override@JsonKey() final String email; +@override@JsonKey() final String password; + final Set _visibleFields; +@override@JsonKey() Set get visibleFields { + if (_visibleFields is EqualUnmodifiableSetView) return _visibleFields; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_visibleFields); +} + +@override@JsonKey() final LoginStatus status; +/// Failure of the last attempt, shown as banner. +@override final AuthFailure? bannerFailure; +/// Address of the last attempt that ended in `emailNotConfirmed`. The +/// resend action uses this one, not whatever is in the field now. +@override final String? unconfirmedEmail; +/// Field that should receive focus after a failed submit. +@override final LoginField? focusField; +/// Incremented whenever [focusField] is set. +@override@JsonKey() final int focusRequest; + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LoginStateCopyWith<_LoginState> get copyWith => __$LoginStateCopyWithImpl<_LoginState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoginState&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&const DeepCollectionEquality().equals(other.visibleFields, _visibleFields)&&(identical(other.status, status) || other.status == status)&&(identical(other.bannerFailure, bannerFailure) || other.bannerFailure == bannerFailure)&&(identical(other.unconfirmedEmail, unconfirmedEmail) || other.unconfirmedEmail == unconfirmedEmail)&&(identical(other.focusField, focusField) || other.focusField == focusField)&&(identical(other.focusRequest, focusRequest) || other.focusRequest == focusRequest)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,email,password,const DeepCollectionEquality().hash(_visibleFields),status,bannerFailure,unconfirmedEmail,focusField,focusRequest); +} + + + +} + +/// @nodoc +abstract mixin class _$LoginStateCopyWith<$Res> implements $LoginStateCopyWith<$Res> { + factory _$LoginStateCopyWith(_LoginState value, $Res Function(_LoginState) _then) = __$LoginStateCopyWithImpl; +@override @useResult +$Res call({ + String email, String password, Set visibleFields, LoginStatus status, AuthFailure? bannerFailure, String? unconfirmedEmail, LoginField? focusField, int focusRequest +}); + + +@override $AuthFailureCopyWith<$Res>? get bannerFailure; + +} +/// @nodoc +class __$LoginStateCopyWithImpl<$Res> + implements _$LoginStateCopyWith<$Res> { + __$LoginStateCopyWithImpl(this._self, this._then); + + final _LoginState _self; + final $Res Function(_LoginState) _then; + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? email = null,Object? password = null,Object? visibleFields = null,Object? status = null,Object? bannerFailure = freezed,Object? unconfirmedEmail = freezed,Object? focusField = freezed,Object? focusRequest = null,}) { + return _then(_LoginState( +email: null == email ? _self.email : email // ignore: cast_nullable_to_non_nullable +as String,password: null == password ? _self.password : password // ignore: cast_nullable_to_non_nullable +as String,visibleFields: null == visibleFields ? _self._visibleFields : visibleFields // ignore: cast_nullable_to_non_nullable +as Set,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LoginStatus,bannerFailure: freezed == bannerFailure ? _self.bannerFailure : bannerFailure // ignore: cast_nullable_to_non_nullable +as AuthFailure?,unconfirmedEmail: freezed == unconfirmedEmail ? _self.unconfirmedEmail : unconfirmedEmail // ignore: cast_nullable_to_non_nullable +as String?,focusField: freezed == focusField ? _self.focusField : focusField // ignore: cast_nullable_to_non_nullable +as LoginField?,focusRequest: null == focusRequest ? _self.focusRequest : focusRequest // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of LoginState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$AuthFailureCopyWith<$Res>? get bannerFailure { + if (_self.bannerFailure == null) { + return null; + } + + return $AuthFailureCopyWith<$Res>(_self.bannerFailure!, (value) { + return _then(_self.copyWith(bannerFailure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/auth/presentation/providers/auth_redirect.dart b/app/lib/features/auth/presentation/providers/auth_redirect.dart index e318d83..e79d840 100644 --- a/app/lib/features/auth/presentation/providers/auth_redirect.dart +++ b/app/lib/features/auth/presentation/providers/auth_redirect.dart @@ -69,10 +69,21 @@ String? safeFromPath(String? from) { if (from.startsWith('//') || from.startsWith(r'/\')) { return null; } + // Browsers drop tabs and line breaks inside URLs (`/\t/evil` -> `//evil`), + // so control characters are never allowed. + if (RegExp(r'[\x00-\x1f\x7f]').hasMatch(from)) { + return null; + } final uri = Uri.tryParse(from); if (uri == null || uri.hasScheme || uri.hasAuthority) { return null; } + // Percent-encoded variants (`/%2F%2Fevil`, `/%5Cevil`) must not decode to a + // protocol-relative path either. + final decoded = Uri.decodeFull(uri.path); + if (decoded.startsWith('//') || decoded.startsWith(r'/\')) { + return null; + } // `/login/` is the same page as `/login` (go_router ignores the trailing // slash), so it is not allowed either. final path = uri.path.length > 1 diff --git a/app/lib/features/auth/presentation/register/register_screen.dart b/app/lib/features/auth/presentation/register/register_screen.dart index 6b36515..032a0b9 100644 --- a/app/lib/features/auth/presentation/register/register_screen.dart +++ b/app/lib/features/auth/presentation/register/register_screen.dart @@ -319,7 +319,7 @@ class _FailureBanner extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = context.l10n; - final retryable = failure is NetworkFailure || failure is Unexpected; + final retryable = failure.isRetryable; return AppBanner( type: AppBannerType.error, typeLabel: l10n.authBannerTypeError, diff --git a/app/lib/features/auth/presentation/register/register_state.dart b/app/lib/features/auth/presentation/register/register_state.dart index 0005b68..697fe3f 100644 --- a/app/lib/features/auth/presentation/register/register_state.dart +++ b/app/lib/features/auth/presentation/register/register_state.dart @@ -56,6 +56,13 @@ abstract class RegisterState with _$RegisterState { const RegisterState._(); + /// No credentials or personal data in logs or error reports. + @override + String toString() => + 'RegisterState(email: , password: , ' + 'displayName: , status: $status, ' + 'bannerFailure: $bannerFailure)'; + bool get isSubmitting => status == RegisterStatus.submitting; EmailError? get emailError => EmailFormat.validate(email); diff --git a/app/lib/features/auth/presentation/register/register_state.freezed.dart b/app/lib/features/auth/presentation/register/register_state.freezed.dart index 3051f2a..f6f37a1 100644 --- a/app/lib/features/auth/presentation/register/register_state.freezed.dart +++ b/app/lib/features/auth/presentation/register/register_state.freezed.dart @@ -44,11 +44,6 @@ int get hashCode { return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,const DeepCollectionEquality().hash(_this.visibleFields),_this.status,_this.commonPasswords,_this.bannerFailure,const DeepCollectionEquality().hash(_this.serverPasswordReasons),_this.serverEmailInvalid,_this.focusField,_this.focusRequest); } -@override -String toString() { - final _this = this as RegisterState; - return 'RegisterState(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, visibleFields: ${_this.visibleFields}, status: ${_this.status}, commonPasswords: ${_this.commonPasswords}, bannerFailure: ${_this.bannerFailure}, serverPasswordReasons: ${_this.serverPasswordReasons}, serverEmailInvalid: ${_this.serverEmailInvalid}, focusField: ${_this.focusField}, focusRequest: ${_this.focusRequest})'; -} } @@ -301,10 +296,6 @@ int get hashCode { return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,const DeepCollectionEquality().hash(_visibleFields),status,commonPasswords,bannerFailure,const DeepCollectionEquality().hash(_serverPasswordReasons),serverEmailInvalid,focusField,focusRequest); } -@override -String toString() { - return 'RegisterState(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, visibleFields: $visibleFields, status: $status, commonPasswords: $commonPasswords, bannerFailure: $bannerFailure, serverPasswordReasons: $serverPasswordReasons, serverEmailInvalid: $serverEmailInvalid, focusField: $focusField, focusRequest: $focusRequest)'; -} } diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 1997a07..7efc12a 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -47,6 +47,10 @@ "authRegisterTitle": "Konto erstellen", "authRegisterSubmit": "Konto erstellen", "authRegisterHaveAccount": "Schon ein Konto? Anmelden", + "authLoginTitle": "Anmelden", + "authLoginSubmit": "Anmelden", + "authLoginForgotPassword": "Passwort vergessen?", + "authLoginCreateAccount": "Konto erstellen", "authCheckEmailTitle": "Prüfe dein Postfach", "authCheckEmailBody": "Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist. Folge dem Link darin, um fortzufahren.", "authCheckEmailDelayHint": "Die E-Mail kann einige Minuten dauern. Schau auch im Spam-Ordner nach.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0dfc746..5893734 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -86,6 +86,10 @@ "authRegisterTitle": "Create account", "authRegisterSubmit": "Create account", "authRegisterHaveAccount": "Already have an account? Sign in", + "authLoginTitle": "Sign in", + "authLoginSubmit": "Sign in", + "authLoginForgotPassword": "Forgot password?", + "authLoginCreateAccount": "Create account", "authCheckEmailTitle": "Check your inbox", "authCheckEmailBody": "We sent you an email if the address is not registered yet. Follow the link in it to continue.", "authCheckEmailDelayHint": "The email can take a few minutes. Also check your spam folder.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index b13789c..b23d9f3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -380,6 +380,30 @@ abstract class AppLocalizations { /// **'Already have an account? Sign in'** String get authRegisterHaveAccount; + /// No description provided for @authLoginTitle. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get authLoginTitle; + + /// No description provided for @authLoginSubmit. + /// + /// In en, this message translates to: + /// **'Sign in'** + String get authLoginSubmit; + + /// No description provided for @authLoginForgotPassword. + /// + /// In en, this message translates to: + /// **'Forgot password?'** + String get authLoginForgotPassword; + + /// No description provided for @authLoginCreateAccount. + /// + /// In en, this message translates to: + /// **'Create account'** + String get authLoginCreateAccount; + /// No description provided for @authCheckEmailTitle. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 88e00cb..b2b7380 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -179,6 +179,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get authRegisterHaveAccount => 'Schon ein Konto? Anmelden'; + @override + String get authLoginTitle => 'Anmelden'; + + @override + String get authLoginSubmit => 'Anmelden'; + + @override + String get authLoginForgotPassword => 'Passwort vergessen?'; + + @override + String get authLoginCreateAccount => 'Konto erstellen'; + @override String get authCheckEmailTitle => 'Prüfe dein Postfach'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 14bab3a..b86fff5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -176,6 +176,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get authRegisterHaveAccount => 'Already have an account? Sign in'; + @override + String get authLoginTitle => 'Sign in'; + + @override + String get authLoginSubmit => 'Sign in'; + + @override + String get authLoginForgotPassword => 'Forgot password?'; + + @override + String get authLoginCreateAccount => 'Create account'; + @override String get authCheckEmailTitle => 'Check your inbox'; diff --git a/app/test/features/auth/presentation/auth_redirect_test.dart b/app/test/features/auth/presentation/auth_redirect_test.dart index 671b194..f248b5a 100644 --- a/app/test/features/auth/presentation/auth_redirect_test.dart +++ b/app/test/features/auth/presentation/auth_redirect_test.dart @@ -150,5 +150,13 @@ void main() { expect(safeFromPath(''), isNull); expect(safeFromPath('javascript:alert(1)'), isNull); }); + + test('rejects encoded and control-character tricks', () { + expect(safeFromPath('/%2F%2Fevil.example'), isNull); + expect(safeFromPath('%2F%2Fevil'), isNull); + expect(safeFromPath('/%5Cevil.example'), isNull); + expect(safeFromPath('/\t/evil.example'), isNull); + expect(safeFromPath('/a\nb'), isNull); + }); }); } diff --git a/app/test/features/auth/presentation/auth_router_test.dart b/app/test/features/auth/presentation/auth_router_test.dart index eb46ecc..fce5c1d 100644 --- a/app/test/features/auth/presentation/auth_router_test.dart +++ b/app/test/features/auth/presentation/auth_router_test.dart @@ -86,7 +86,7 @@ void main() { expect(currentPath(router), '/login'); expect(currentUri(router).queryParameters['from'], '/private?tab=1'); - expect(find.byKey(const Key('auth_placeholder_login')), findsOneWidget); + expect(find.byKey(const Key('login_screen')), findsOneWidget); }); testWidgets('signing in on /login returns to from', (tester) async { @@ -116,12 +116,12 @@ void main() { }); testWidgets( - 'routes of spec 5.3 exist (01a: real screens, rest placeholders)', + 'routes of spec 5.3 exist (01b: login real, rest placeholders)', (tester) async { final (container: _, :router) = await start(tester); for (final (path, key) in [ - ('/login', 'auth_placeholder_login'), + ('/login', 'login_screen'), ('/register', 'register_screen'), ('/register/check-email', 'check_email_screen'), ('/forgot-password', 'auth_placeholder_forgot_password'), diff --git a/app/test/features/auth/presentation/login/login_controller_test.dart b/app/test/features/auth/presentation/login/login_controller_test.dart new file mode 100644 index 0000000..23b9be7 --- /dev/null +++ b/app/test/features/auth/presentation/login/login_controller_test.dart @@ -0,0 +1,242 @@ +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/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 '../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, loginControllerProvider); + keepAlive(container, resendControllerProvider); + }); + + LoginController notifier() => + container.read(loginControllerProvider.notifier); + LoginState state() => container.read(loginControllerProvider); + + void stubSignIn(Result result) => + when(() => repository.signIn(any(), any())) + .thenAnswer((_) async => result); + + void fill([String email = ' Anna@Example.com ', String password = 'pw']) { + notifier() + ..emailChanged(email) + ..passwordChanged(password); + } + + group('KTO-01 Kriterium 5: sign in', () { + test('sends the trimmed address, continues to the start page', () async { + stubSignIn(ok()); + fill(); + + final target = await notifier().submit(); + + verify(() => repository.signIn('Anna@Example.com', 'pw')).called(1); + expect(target, '/'); + // The form is left after success; it is not unlocked again. + expect(state().isSubmitting, isTrue); + expect(state().bannerFailure, isNull); + }); + + test('continues to a safe from path', () async { + stubSignIn(ok()); + fill(); + + expect(await notifier().submit(from: '/games/7?tab=1'), '/games/7?tab=1'); + }); + + for (final unsafe in [ + 'https://evil.example/x', + '//evil.example', + r'/\evil.example', + 'javascript:alert(1)', + 'games', + '/login', + '/register', + '/reset-password', + '/auth/confirm?token_hash=x', + '/%2F%2Fevil.example', + '%2F%2Fevil', + '/\t/evil.example', + '/%5Cevil.example', + '', + ]) { + test('ignores the unsafe from "$unsafe"', () async { + stubSignIn(ok()); + fill(); + + expect(await notifier().submit(from: unsafe), '/'); + }); + } + + test('empty fields are not sent and ask for focus', () async { + await notifier().submit(); + + verifyNever(() => repository.signIn(any(), any())); + expect(state().shownEmailError, isTrue); + expect(state().shownPasswordRequired, isTrue); + expect(state().focusField, LoginField.email); + expect(state().focusRequest, 1); + expect(state().isSubmitting, isFalse); + }); + + test('an invalid address is not sent', () async { + fill('anna', 'pw'); + + expect(await notifier().submit(), isNull); + + verifyNever(() => repository.signIn(any(), any())); + expect(state().shownEmailError, isTrue); + expect(state().shownPasswordRequired, isFalse); + }); + + test('blur shows the error of that field only', () { + notifier().fieldBlurred(LoginField.password); + + expect(state().shownPasswordRequired, isTrue); + expect(state().shownEmailError, isFalse); + }); + + test('a second submit while one runs is ignored', () async { + final completer = Completer>(); + when(() => repository.signIn(any(), any())) + .thenAnswer((_) => completer.future); + fill(); + + final first = notifier().submit(); + await Future.delayed(Duration.zero); + expect(state().isSubmitting, isTrue); + expect(await notifier().submit(), isNull); + completer.complete(ok()); + await first; + + verify(() => repository.signIn(any(), any())).called(1); + }); + }); + + group('no credentials in logs', () { + test('toString masks email and password', () { + fill('anna@example.com', 'geheim-und-lang'); + + final text = state().toString(); + + expect(text, isNot(contains('anna@example.com'))); + expect(text, isNot(contains('geheim-und-lang'))); + expect(text, contains('')); + }); + }); + + group('KTO-01 Kriterium 7: failures', () { + test('invalid credentials keep the input and show the failure', () async { + stubSignIn(failed(const AuthFailure.invalidCredentials())); + fill(); + + expect(await notifier().submit(), isNull); + + expect(state().bannerFailure, const AuthFailure.invalidCredentials()); + expect(state().email, ' Anna@Example.com '); + expect(state().password, 'pw'); + expect(state().shownEmailError, isFalse); + expect(state().shownPasswordRequired, isFalse); + expect(state().showsNotConfirmed, isFalse); + }); + + for (final failure in const [ + AuthFailure.rateLimited(), + AuthFailure.network(), + AuthFailure.unexpected(), + ]) { + test('${failure.runtimeType} is shown as banner', () async { + stubSignIn(failed(failure)); + fill(); + + await notifier().submit(); + + expect(state().bannerFailure, failure); + expect(state().isSubmitting, isFalse); + }); + } + + test('a new attempt clears the old failure', () async { + stubSignIn(failed(const AuthFailure.invalidCredentials())); + fill(); + await notifier().submit(); + stubSignIn(ok()); + + await notifier().submit(); + + expect(state().bannerFailure, isNull); + }); + }); + + group('KTO-01 Kriterium 6: unconfirmed account', () { + setUp(() { + stubSignIn(failed(const AuthFailure.emailNotConfirmed())); + fill(); + }); + + test('marks the address for resend', () async { + await notifier().submit(); + + expect(state().showsNotConfirmed, isTrue); + expect(state().unconfirmedEmail, 'Anna@Example.com'); + }); + + test('resend uses the address of the attempt, not the field now', () async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => ok()); + await notifier().submit(); + notifier().emailChanged('other@example.com'); + + await notifier().resendConfirmation(); + + verify(() => repository.resendConfirmation('Anna@Example.com')).called(1); + expect(container.read(resendControllerProvider).sent, isTrue); + }); + + test('emailSendLimited shows the throttle hint (login origin)', () async { + when( + () => repository.resendConfirmation(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + await notifier().submit(); + + await notifier().resendConfirmation(); + + final resend = container.read(resendControllerProvider); + expect(resend.failure, const AuthFailure.rateLimited()); + expect(resend.sent, isFalse); + }); + + test('resend without a pending attempt does nothing', () async { + await notifier().resendConfirmation(); + + verifyNever(() => repository.resendConfirmation(any())); + }); + + test('a new attempt resets the resend result', () async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => ok()); + await notifier().submit(); + await notifier().resendConfirmation(); + expect(container.read(resendControllerProvider).sent, isTrue); + + await notifier().submit(); + + expect(container.read(resendControllerProvider).sent, isFalse); + }); + }); +} diff --git a/app/test/features/auth/presentation/login/login_screen_test.dart b/app/test/features/auth/presentation/login/login_screen_test.dart new file mode 100644 index 0000000..54fe2ce --- /dev/null +++ b/app/test/features/auth/presentation/login/login_screen_test.dart @@ -0,0 +1,557 @@ +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/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(); + +void main() { + late MockAuthRepository repository; + + setUpAll(registerAuthFallbacks); + + setUp(() { + repository = MockAuthRepository(); + when(() => repository.signIn(any(), any())).thenAnswer((_) async => ok()); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => ok()); + }); + + Future open( + WidgetTester tester, { + String location = '/login', + 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: location, + routes: [ + ...authRoutes, + GoRoute(path: '/', builder: (_, _) => const Text('home-page')), + GoRoute( + path: '/private', + builder: (_, _) => const Text('private-page'), + ), + ], + ); + addTearDown(router.dispose); + final container = await pumpApp( + tester, + router: router, + locale: locale, + themeMode: themeMode, + textScale: textScale, + overrides: authUiOverrides(repository, locale: locale), + ); + return (container: container, router: router); + } + + Future type(WidgetTester tester, String key, String text) async { + await tester.ensureVisible(fieldOf(key)); + await tester.enterText(fieldOf(key), text); + await tester.pump(); + } + + Future tapKey(WidgetTester tester, String key) async { + final finder = find.byKey(Key(key)); + await tester.ensureVisible(finder); + await tester.tap(finder); + await tester.pump(); + } + + Future fill(WidgetTester tester) async { + await type(tester, 'login_email', 'anna@example.com'); + await type(tester, 'login_password', 'geheim-und-lang'); + } + + Future submit(WidgetTester tester) async { + await tapKey(tester, 'login_submit'); + await tester.pumpAndSettle(); + } + + void stubSignInFailure(AuthFailure failure) => + when(() => repository.signIn(any(), any())) + .thenAnswer((_) async => failed(failure)); + + group('KTO-01 Kriterium 5: sign in', () { + testWidgets('signs in and leaves the page', (tester) async { + final (:container, :router) = await open(tester); + await fill(tester); + + await submit(tester); + + verify(() => repository.signIn('anna@example.com', 'geheim-und-lang')) + .called(1); + expect(currentPath(router), '/'); + expect(find.text('home-page'), findsOneWidget); + }); + + testWidgets('the enter key in the password field submits', (tester) async { + final (:container, :router) = await open(tester); + await fill(tester); + + await tester.showKeyboard(fieldOf('login_password')); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + verify(() => repository.signIn(any(), any())).called(1); + expect(currentPath(router), '/'); + }); + + testWidgets('returns to a valid from path', (tester) async { + final (:container, :router) = await open( + tester, + location: '/login?from=%2Fprivate', + ); + await fill(tester); + + await submit(tester); + + expect(currentPath(router), '/private'); + }); + + for (final from in [ + 'https://evil.example', + '//evil.example', + '/register', + ]) { + testWidgets('ignores the unsafe from "$from"', (tester) async { + final (:container, :router) = await open( + tester, + location: Uri( + path: '/login', + queryParameters: {'from': from}, + ).toString(), + ); + await fill(tester); + + await submit(tester); + + expect(currentPath(router), '/'); + }); + } + + testWidgets('fields carry autofill hints and keyboard types', ( + tester, + ) async { + await open(tester); + + final email = tester.widget(fieldOf('login_email')); + expect(email.autofillHints, contains(AutofillHints.username)); + expect(email.autofillHints, contains(AutofillHints.email)); + expect(email.keyboardType, TextInputType.emailAddress); + expect(email.textInputAction, TextInputAction.next); + final password = tester.widget(fieldOf('login_password')); + expect(password.autofillHints, contains(AutofillHints.password)); + expect( + password.autofillHints, + isNot(contains(AutofillHints.newPassword)), + ); + expect(password.obscureText, isTrue); + expect(password.textInputAction, TextInputAction.done); + }); + + testWidgets('empty fields show errors at the fields and send nothing', ( + tester, + ) async { + await open(tester); + + await submit(tester); + + expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); + expect(find.text(_de.authPasswordRequired), findsOneWidget); + verifyNever(() => repository.signIn(any(), any())); + }); + + testWidgets('double tap sends once and locks the form', (tester) async { + final completer = Completer>(); + when(() => repository.signIn(any(), any())) + .thenAnswer((_) => completer.future); + await open(tester); + await fill(tester); + + await tapKey(tester, 'login_submit'); + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + + expect(tester.widget(fieldOf('login_email')).enabled, isFalse); + expect( + tester.widget(fieldOf('login_password')).enabled, + isFalse, + ); + completer.complete(ok()); + await tester.pumpAndSettle(); + verify(() => repository.signIn(any(), any())).called(1); + }); + + testWidgets('links lead to register and forgot password', (tester) async { + final (:container, :router) = await open(tester); + + await tapKey(tester, 'login_register_link'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/register'); + + router.go('/login'); + await tester.pumpAndSettle(); + await tapKey(tester, 'login_forgot_link'); + await tester.pumpAndSettle(); + expect(currentPath(router), '/forgot-password'); + }); + }); + + group('KTO-01 Kriterium 7: generic failure', () { + testWidgets('invalid credentials: one banner, no field error, input kept', ( + tester, + ) async { + stubSignInFailure(const AuthFailure.invalidCredentials()); + await open(tester); + await fill(tester); + + await submit(tester); + + expect(find.text(_de.authErrorInvalidCredentials), findsOneWidget); + expect(find.byKey(const Key('login_banner')), findsOneWidget); + expect(find.text(_de.authErrorEmailInvalid), findsNothing); + expect(find.text(_de.authPasswordRequired), findsNothing); + expect( + tester.widget(fieldOf('login_email')).controller!.text, + 'anna@example.com', + ); + expect( + tester.widget(fieldOf('login_password')).controller!.text, + 'geheim-und-lang', + ); + }); + + testWidgets( + 'wrong email and wrong password show exactly the same screen text', + (tester) async { + // Screen level only: the repository mock returns invalidCredentials + // for both cases, so this proves the screen adds nothing that tells + // them apart. The real proof that an unknown address and a wrong + // password yield the same `invalid_credentials` answer is the API + // E2E test (T3). + stubSignInFailure(const AuthFailure.invalidCredentials()); + final handle = tester.ensureSemantics(); + await open(tester); + await type(tester, 'login_email', 'unknown@example.com'); + await type(tester, 'login_password', 'irgendein-passwort'); + await submit(tester); + final wrongEmail = visibleTexts(tester); + + await type(tester, 'login_email', 'anna@example.com'); + await type(tester, 'login_password', 'falsches-passwort'); + await submit(tester); + final wrongPassword = visibleTexts(tester); + + expect(wrongEmail, wrongPassword); + // Fixed wording, independent of the l10n getter. + const fixed = 'E-Mail-Adresse oder Passwort ist nicht korrekt.'; + expect(wrongEmail, contains(fixed)); + expect(_de.authErrorInvalidCredentials, fixed); + expect(find.text(fixed), findsOneWidget); + expect(find.bySemanticsLabel('Fehler: $fixed'), findsOneWidget); + handle.dispose(); + }, + ); + + testWidgets('throttling (429) shows the throttle hint', (tester) async { + stubSignInFailure(const AuthFailure.rateLimited()); + await open(tester); + await fill(tester); + + await submit(tester); + + expect(find.text(_de.authErrorRateLimited), findsOneWidget); + expect(find.text(_de.authActionRetry), findsNothing); + }); + + testWidgets('offline: shows the hint, keeps the input, can retry', ( + tester, + ) async { + var calls = 0; + when(() => repository.signIn(any(), any())).thenAnswer((_) async { + calls++; + return calls == 1 ? failed(const AuthFailure.network()) : ok(); + }); + final (:container, :router) = await open(tester); + await fill(tester); + + await submit(tester); + expect(find.text(_de.authErrorNetwork), findsOneWidget); + expect( + tester.widget(fieldOf('login_email')).controller!.text, + 'anna@example.com', + ); + + await tester.tap(find.text(_de.authActionRetry)); + await tester.pumpAndSettle(); + + verify(() => repository.signIn(any(), any())).called(2); + expect(currentPath(router), '/'); + }); + }); + + group('KTO-01 Kriterium 6: unconfirmed account', () { + setUp(() => stubSignInFailure(const AuthFailure.emailNotConfirmed())); + + testWidgets('shows the hint and resends the confirmation', (tester) async { + await open(tester); + await fill(tester); + + await submit(tester); + expect(find.text(_de.authErrorEmailNotConfirmed), findsOneWidget); + expect(find.text(_de.authErrorInvalidCredentials), findsNothing); + + await tester.tap(find.text(_de.authActionResendConfirmation)); + await tester.pumpAndSettle(); + + verify(() => repository.resendConfirmation('anna@example.com')).called(1); + expect(find.byKey(const Key('login_resent_banner')), findsOneWidget); + expect(find.text(_de.authCheckEmailResent), findsOneWidget); + }); + + testWidgets('emailSendLimited shows the throttle hint, not success', ( + tester, + ) async { + when( + () => repository.resendConfirmation(any()), + ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); + await open(tester); + await fill(tester); + await submit(tester); + + await tester.tap(find.text(_de.authActionResendConfirmation)); + await tester.pumpAndSettle(); + + expect(find.text(_de.authErrorRateLimited), findsOneWidget); + expect(find.byKey(const Key('login_resent_banner')), findsNothing); + }); + + testWidgets('the resend action is locked while sending', (tester) async { + final completer = Completer>(); + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) => completer.future); + await open(tester); + await fill(tester); + await submit(tester); + + await tester.tap(find.text(_de.authActionResendConfirmation)); + await tester.pump(); + + expect(find.text(_de.authActionResendConfirmation), findsNothing); + completer.complete(ok()); + await tester.pumpAndSettle(); + verify(() => repository.resendConfirmation(any())).called(1); + }); + + testWidgets('offline resend shows the offline hint', (tester) async { + when(() => repository.resendConfirmation(any())) + .thenAnswer((_) async => failed(const AuthFailure.network())); + await open(tester); + await fill(tester); + await submit(tester); + + await tester.tap(find.text(_de.authActionResendConfirmation)); + await tester.pumpAndSettle(); + + expect(find.text(_de.authErrorNetwork), findsOneWidget); + }); + }); + + group('KTO-01 Kriterium 5: autofill context', () { + // Only calls that save the credentials (`shouldSave: true`). + int finishCalls(WidgetTester tester) => tester.testTextInput.log + .where( + (call) => + call.method == 'TextInput.finishAutofillContext' && + call.arguments == true, + ) + .length; + + testWidgets('is finished after a successful sign-in', (tester) async { + await open(tester); + await fill(tester); + tester.testTextInput.log.clear(); + + await submit(tester); + + // Our explicit call, plus the AutofillGroup committing on disposal + // when the screen is replaced. + expect(finishCalls(tester), greaterThanOrEqualTo(1)); + }); + + testWidgets('is finished even if the router already removed the screen', ( + tester, + ) async { + final completer = Completer>(); + when(() => repository.signIn(any(), any())) + .thenAnswer((_) => completer.future); + final (:container, :router) = await open(tester); + await fill(tester); + await tapKey(tester, 'login_submit'); + // The status change makes the router redirect away before the + // sign-in future completes. + router.go('/private'); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('login_screen')), findsNothing); + tester.testTextInput.log.clear(); + + completer.complete(ok()); + await tester.pumpAndSettle(); + + expect(finishCalls(tester), 1); + expect(currentPath(router), '/private'); + }); + + testWidgets('is not finished after a failed sign-in', (tester) async { + stubSignInFailure(const AuthFailure.invalidCredentials()); + await open(tester); + await fill(tester); + tester.testTextInput.log.clear(); + + await submit(tester); + + expect(finishCalls(tester), 0); + }); + }); + + group('Zustände Kriterium 3: banners and focus', () { + testWidgets('unexpected error shows the banner with retry', (tester) async { + stubSignInFailure(const AuthFailure.unexpected()); + await open(tester); + await fill(tester); + + await submit(tester); + + expect(find.text(_de.authErrorUnexpected), findsOneWidget); + expect(find.text(_de.authActionRetry), findsOneWidget); + stubSignInFailure(const AuthFailure.unexpected()); + await tester.tap(find.text(_de.authActionRetry)); + await tester.pumpAndSettle(); + verify(() => repository.signIn(any(), any())).called(2); + }); + + testWidgets('a failed submit focuses the first invalid field', ( + tester, + ) async { + await open(tester); + + await submit(tester); + expect( + tester.widget(fieldOf('login_email')).focusNode!.hasFocus, + isTrue, + ); + + await type(tester, 'login_email', 'anna@example.com'); + await submit(tester); + expect( + tester.widget(fieldOf('login_password')).focusNode!.hasFocus, + isTrue, + ); + }); + }); + + group('QUE-05 Kriterium 2: de and en', () { + testWidgets('english texts', (tester) async { + await open(tester, locale: const Locale('en')); + + expect(find.text(_en.authLoginTitle), findsWidgets); + expect(find.text(_en.authLoginForgotPassword), findsOneWidget); + expect(find.text(_en.authLoginCreateAccount), findsOneWidget); + expect(find.text(_de.authLoginForgotPassword), 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(); + stubSignInFailure(const AuthFailure.emailNotConfirmed()); + await open(tester, themeMode: mode); + await fill(tester); + await submit(tester); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + for (final mode in [ThemeMode.light, ThemeMode.dark]) { + testWidgets('error banner meets the contrast guideline ($mode)', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + stubSignInFailure(const AuthFailure.invalidCredentials()); + await open(tester, themeMode: mode); + await fill(tester); + await submit(tester); + expect(find.byKey(const Key('login_banner')), findsOneWidget); + + await expectLater(tester, meetsGuideline(textContrastGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + handle.dispose(); + }); + } + + testWidgets('the title is a heading and the banner a live region', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + stubSignInFailure(const AuthFailure.invalidCredentials()); + await open(tester); + await fill(tester); + await submit(tester); + + expect( + find.bySemanticsLabel( + 'Fehler: E-Mail-Adresse oder Passwort ist nicht korrekt.', + ), + findsOneWidget, + ); + expect( + tester.getSemantics(find.text(_de.authLoginTitle).first), + matchesSemantics(label: _de.authLoginTitle, isHeader: true), + ); + handle.dispose(); + }); + + testWidgets('text scale 2.0 on a small phone does not overflow', ( + tester, + ) async { + stubSignInFailure(const AuthFailure.emailNotConfirmed()); + await open(tester, textScale: 2, size: const Size(320, 640)); + await fill(tester); + await submit(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('wide screens show a centered card', (tester) async { + await open(tester, size: const Size(1200, 900)); + + final card = tester.getRect(find.byType(Card).first); + expect(card.width, lessThanOrEqualTo(480)); + expect(card.center.dx, closeTo(600, 1)); + }); + }); +} diff --git a/app/test/features/auth/presentation/register/register_controller_test.dart b/app/test/features/auth/presentation/register/register_controller_test.dart index ddc32e0..1eb6ed0 100644 --- a/app/test/features/auth/presentation/register/register_controller_test.dart +++ b/app/test/features/auth/presentation/register/register_controller_test.dart @@ -71,6 +71,24 @@ void main() { ..termsAcceptedChanged(value: true); } + group('no personal data in logs', () { + test('toString masks email, password and display name', () { + final container = makeContainer(); + fillValid( + container.read(registerControllerProvider.notifier), + email: 'anna@example.com', + name: 'Anna Geheim', + ); + + final text = container.read(registerControllerProvider).toString(); + + expect(text, isNot(contains('anna@example.com'))); + expect(text, isNot(contains('geheim-und-lang'))); + expect(text, isNot(contains('Anna Geheim'))); + expect(text, contains('')); + }); + }); + group('KTO-01 Kriterium 1: submit', () { test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async { final container = makeContainer(locale: const Locale('en')); diff --git a/docs/design/auth-screens.md b/docs/design/auth-screens.md index 396cd9d..473b832 100644 --- a/docs/design/auth-screens.md +++ b/docs/design/auth-screens.md @@ -57,19 +57,35 @@ Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auc (Modus Reset: -> /forgot-password) ``` -### LoginScreen (`/login`) +### LoginScreen (`/login`, umgesetzt in 01b) ``` Anmelden -! Fehler-Banner: authInvalidCredentials | rateLimited | network (über dem Formular, nie am Feld) -! Info-Banner emailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden" -E-Mail [ ] -Passwort [ ••••• (Auge) ] +! Fehler-Banner: authErrorInvalidCredentials | authErrorRateLimited | authErrorNetwork (+ "Erneut versuchen") +! Info-Banner authErrorEmailNotConfirmed + Aktion "Bestätigungs-E-Mail erneut senden" +! Info-Banner "Wir haben die E-Mail erneut gesendet ..." | Fehler-Banner (authErrorRateLimited / authErrorNetwork) nach dem Resend +E-Mail [ ] Fehler (nur lokal): authErrorEmailInvalid +Passwort [ ••••• (Auge) ] Fehler (nur lokal): authPasswordRequired ( Anmelden ) - - + -> /forgot-password + -> /register ``` +Umsetzungsentscheidungen: + +- Falsche Zugangsdaten stehen nie am Feld, sondern nur im Banner; Text für falsche E-Mail und falsches Passwort + 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). +- Ein neuer Anmeldeversuch setzt das Resend-Ergebnis zurück. +- Erfolg: `context.go()`. `from` wird mit `safeFromPath` geprüft (nur interne Pfade, keine Auth-Seiten, + kein `//`, `/\` oder Schema); sonst Startseite. Zusätzlich leitet der Router bei Status `signedIn` von `/login` um. +- Autofill: `AutofillGroup`, E-Mail `username` + `email`, Passwort `password`; `TextInput.finishAutofillContext()` bei Erfolg. +- Neue l10n-Schlüssel: `authLoginTitle`, `authLoginSubmit`, `authLoginForgotPassword`, `authLoginCreateAccount`. +- Widget-Keys: `login_screen`, `login_email`, `login_password`, `login_submit`, `login_banner`, + `login_not_confirmed_banner`, `login_resent_banner`, `login_resend_error_banner`, `login_forgot_link`, `login_register_link`. + ### ForgotPasswordScreen (`/forgot-password`) ```