feat(auth): KTO-01 Anmeldung (01b) – LoginScreen (3/4) #35
23 changed files with 1752 additions and 24 deletions
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RouteBase> 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(),
|
||||
|
|
|
|||
|
|
@ -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<String?> 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<void> resendConfirmation() async {
|
||||
final email = state.unconfirmedEmail;
|
||||
if (email == null) {
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(resendControllerProvider.notifier)
|
||||
.resend(email, origin: ResendOrigin.login);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<LoginController, LoginState> {
|
||||
/// 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<LoginState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$loginControllerHash() => r'693066c260db8abe8f70d04b88b216b2e0411579';
|
||||
|
||||
/// Logic of the sign-in form (KTO-01 Kriterien 5, 6 und 7).
|
||||
|
||||
abstract class _$LoginController extends $Notifier<LoginState> {
|
||||
LoginState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
WhenComplete runBuild() {
|
||||
final ref = this.ref as $Ref<LoginState, LoginState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<LoginState, LoginState>,
|
||||
LoginState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
return element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
246
app/lib/features/auth/presentation/login/login_screen.dart
Normal file
246
app/lib/features/auth/presentation/login/login_screen.dart
Normal file
|
|
@ -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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
75
app/lib/features/auth/presentation/login/login_state.dart
Normal file
75
app/lib/features/auth/presentation/login/login_state.dart
Normal file
|
|
@ -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(<LoginField>{}) Set<LoginField> 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: <redacted>, password: <redacted>, 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;
|
||||
}
|
||||
|
|
@ -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>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$LoginState {
|
||||
|
||||
String get email; String get password; Set<LoginField> 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<LoginState> get copyWith => _$LoginStateCopyWithImpl<LoginState>(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<LoginField> 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<LoginField>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( String email, String password, Set<LoginField> 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 extends Object?>(TResult Function( String email, String password, Set<LoginField> 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 extends Object?>(TResult? Function( String email, String password, Set<LoginField> 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<LoginField> visibleFields = const <LoginField>{}, 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<LoginField> _visibleFields;
|
||||
@override@JsonKey() Set<LoginField> 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<LoginField> 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<LoginField>,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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: <redacted>, password: <redacted>, '
|
||||
'displayName: <redacted>, status: $status, '
|
||||
'bannerFailure: $bannerFailure)';
|
||||
|
||||
bool get isSubmitting => status == RegisterStatus.submitting;
|
||||
|
||||
EmailError? get emailError => EmailFormat.validate(email);
|
||||
|
|
|
|||
|
|
@ -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)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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<void, AuthFailure> 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<Result<void, AuthFailure>>();
|
||||
when(() => repository.signIn(any(), any()))
|
||||
.thenAnswer((_) => completer.future);
|
||||
fill();
|
||||
|
||||
final first = notifier().submit();
|
||||
await Future<void>.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('<redacted>'));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
557
app/test/features/auth/presentation/login/login_screen_test.dart
Normal file
557
app/test/features/auth/presentation/login/login_screen_test.dart
Normal file
|
|
@ -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<AuthUiHandle> 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<void> type(WidgetTester tester, String key, String text) async {
|
||||
await tester.ensureVisible(fieldOf(key));
|
||||
await tester.enterText(fieldOf(key), text);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> tapKey(WidgetTester tester, String key) async {
|
||||
final finder = find.byKey(Key(key));
|
||||
await tester.ensureVisible(finder);
|
||||
await tester.tap(finder);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Future<void> fill(WidgetTester tester) async {
|
||||
await type(tester, 'login_email', 'anna@example.com');
|
||||
await type(tester, 'login_password', 'geheim-und-lang');
|
||||
}
|
||||
|
||||
Future<void> 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<TextField>(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<TextField>(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<Result<void, AuthFailure>>();
|
||||
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<TextField>(fieldOf('login_email')).enabled, isFalse);
|
||||
expect(
|
||||
tester.widget<TextField>(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<TextField>(fieldOf('login_email')).controller!.text,
|
||||
'anna@example.com',
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(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<TextField>(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<Result<void, AuthFailure>>();
|
||||
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<Result<void, AuthFailure>>();
|
||||
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<TextField>(fieldOf('login_email')).focusNode!.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await type(tester, 'login_email', 'anna@example.com');
|
||||
await submit(tester);
|
||||
expect(
|
||||
tester.widget<TextField>(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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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('<redacted>'));
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
|
|
|
|||
|
|
@ -57,19 +57,35 @@ Der Link ist 1 Stunde gültig. Die E-Mail kann einige Minuten dauern. Schau auc
|
|||
<Zurück zur Anmeldung> (Modus Reset: <Andere E-Mail-Adresse eingeben> -> /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 )
|
||||
<Passwort vergessen?>
|
||||
<Konto erstellen>
|
||||
<Passwort vergessen?> -> /forgot-password
|
||||
<Konto erstellen> -> /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 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.
|
||||
- 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`)
|
||||
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in a new issue