feat(auth): KTO-01 Registrierung (01a) – Screens, Auth-Domäne, Formular-Komponenten (2/4) #34

Merged
cschlaefke merged 5 commits from feature/1-01a-registrierung into main 2026-11-03 07:56:15 +00:00
45 changed files with 14763 additions and 2 deletions
Showing only changes of commit 54337ffc59 - Show all commits

32
app/THIRD_PARTY.md Normal file
View file

@ -0,0 +1,32 @@
# Drittanbieter-Material in der App
## SecLists (Passwortliste)
Betrifft `assets/security/common-passwords.txt` (Herkunft und Filter:
`assets/security/README.md`). Lizenz: MIT, Wortlaut wie im Repository
https://github.com/danielmiessler/SecLists (Commit
`68bcd12457978649d41ae4f56e3b5d2db2b72560`):
```text
MIT License
Copyright (c) 2018 Daniel Miessler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```

View file

@ -0,0 +1,40 @@
# Liste häufiger Passwörter
`common-passwords.txt` wird von der Registrierung und vom Passwort-Reset genutzt
(KTO-01 Kriterium 3, Spec #1 § 5.6). Die Liste wird nur im Client geprüft; es
gibt keinen Abgleich mit externen Diensten (kein HIBP).
## Format
- Eine Zeile je Eintrag, UTF-8, LF, Kleinschreibung, ohne Leerraum am Rand.
- Nur Einträge mit mindestens 10 Zeichen (kürzere fängt die Längenregel).
- Einmalig, nach Häufigkeit sortiert (deutsche Einträge zuerst).
- 10.000 Einträge, ca. 118 KB.
- Der Vergleich in der App erfolgt ohne Groß-/Kleinschreibung und nach Trim.
## Quelle und Lizenz
SecLists von Daniel Miessler, Lizenz MIT (Volltext in `app/THIRD_PARTY.md`),
Commit `68bcd12457978649d41ae4f56e3b5d2db2b72560`, Verzeichnis
`Passwords/Common-Credentials/`:
| Datei | Verwendung |
|---|---|
| `Language-Specific/German_common-password-list-top-100000.txt` | die ersten 4.000 Einträge mit mindestens 10 Zeichen |
| `xato-net-10-million-passwords-1000000.txt` | die ersten 6.000 weiteren Einträge mit mindestens 10 Zeichen |
**Offene Lizenzfrage:** Die MIT-Lizenz gilt für das SecLists-Repository. Die
einzelnen Listen stammen aus Datenlecks bzw. Sammlungen Dritter (die xato-Liste
von Mark Burnett); eine eigene Lizenz je Liste nennt SecLists nicht. Die Spec
(§ 5.6) nennt SecLists (MIT) als Kandidaten. Die abschließende Freigabe liegt
bei Chris. Die Liste enthält ausschließlich Passwörter, keine Konten oder
E-Mail-Adressen.
## Neu erzeugen
```bash
python3 scripts/build-common-passwords.py <xato-1000000.txt> <german-top-100000.txt> app/assets/security/common-passwords.txt
```
Das Skript liegt unter `scripts/build-common-passwords.py` (Filter, Reihenfolge
und Grenzen stehen im Kopf der Datei).

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/routing/not_found_screen.dart';
import 'package:shuffle/core/routing/redirect_source.dart';
import 'package:shuffle/features/auth/presentation/auth_route_placeholder.dart';
import 'package:shuffle/features/home/presentation/home_screen.dart';
part 'app_router.g.dart';
@ -21,6 +22,7 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
),
...authRoutes,
],
);
ref.onDispose(router.dispose);

View file

@ -48,4 +48,4 @@ final class AppRouterProvider
}
}
String _$appRouterHash() => r'c3ba8cecc011ab4327ceb2284a58118355176803';
String _$appRouterHash() => r'f4f391f8abc008f4a7b1a88149822bda24f42137';

View file

@ -1,4 +1,16 @@
/// Pfade der App. Weitere Routen kommen mit den Features.
abstract final class AppRoutes {
static const home = '/';
// Auth (Spec #1 § 5.3)
static const login = '/login';
static const register = '/register';
static const registerCheckEmail = '/register/check-email';
static const forgotPassword = '/forgot-password';
static const authConfirm = '/auth/confirm';
static const resetPassword = '/reset-password';
/// Query-Parameter der Anmeldeseite: Pfad, zu dem nach dem Login
/// zurückgekehrt wird.
static const fromParam = 'from';
}

View file

@ -0,0 +1,32 @@
import 'package:flutter/services.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
/// Lädt `assets/security/common-passwords.txt` (Herkunft:
/// `assets/security/README.md`).
class AssetCommonPasswordList {
const AssetCommonPasswordList({this.bundle});
static const assetPath = 'assets/security/common-passwords.txt';
final AssetBundle? bundle;
/// Liest und parst die Datei. Der Aufrufer lädt lazy (erster Zugriff auf ein
/// Passwortfeld) und hält das Ergebnis.
Future<CommonPasswordList> load() async {
final text = await (bundle ?? rootBundle).loadString(assetPath);
return parseCommonPasswords(text);
}
}
/// Zerlegt den Dateiinhalt: eine Zeile je Eintrag, Kleinschreibung, getrimmt,
/// leere Zeilen und `#`-Kommentare entfallen.
CommonPasswordList parseCommonPasswords(String text) {
final entries = <String>{};
for (final line in text.split('\n')) {
final entry = line.trim().toLowerCase();
if (entry.isNotEmpty && !entry.startsWith('#')) {
entries.add(entry);
}
}
return SetCommonPasswordList(entries);
}

View file

@ -0,0 +1,120 @@
import 'dart:async';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// Aufruf, bei dem ein Fehler auftrat. Manche Zuordnungen hängen davon ab
/// (z. B. HTTP 500 nur beim `signUp`).
enum AuthOperation {
signUp,
signIn,
resend,
requestPasswordReset,
verifyLink,
updatePassword,
}
/// Übersetzt Ausnahmen von `supabase_flutter` in [AuthFailure]
/// (Mapping-Tabelle: Spec #1 § 4.4).
///
/// Befund zu `gotrue` 2.27.3 (A1-Prüfung der Spec): Jede Antwort mit Status
/// >= 500 wird als [AuthRetryableFetchException] geworfen, mit dem rohen Body
/// in `message`, dem Status in `statusCode` und `code == null`. Ein
/// Trigger-Fehler beim `signUp` (HTTP 500) ist deshalb nur am Status
/// erkennbar; die Prüfung muss vor dem Netzwerkfall stehen.
AuthFailure mapAuthError(Object error, AuthOperation operation) {
if (error is AuthWeakPasswordException) {
return AuthFailure.weakPassword(_weakReasons(error.reasons));
}
if (error is AuthRetryableFetchException) {
if (operation == AuthOperation.signUp && error.statusCode == '500') {
return AuthFailure.registrationRejected(
reason: registrationRejectionReasonFrom(error.message),
);
}
// Netzwerkfehler (kein Status) und sonstige 5xx (Gateway, Ausfall).
return const AuthFailure.network();
}
if (error is AuthSessionMissingException) {
return const AuthFailure.unexpected();
}
if (error is AuthException) {
return _mapApiError(error, operation);
}
if (error is TimeoutException || _isTransportError(error)) {
return const AuthFailure.network();
}
return const AuthFailure.unexpected();
}
AuthFailure _mapApiError(AuthException error, AuthOperation operation) {
switch (error.code) {
case 'invalid_credentials':
return const AuthFailure.invalidCredentials();
case 'email_not_confirmed':
return const AuthFailure.emailNotConfirmed();
case 'over_email_send_rate_limit':
return switch (operation) {
AuthOperation.signUp ||
AuthOperation.requestPasswordReset ||
AuthOperation.resend => const AuthFailure.emailSendLimited(),
_ => const AuthFailure.rateLimited(),
};
case 'over_request_rate_limit':
return const AuthFailure.rateLimited();
case 'weak_password':
return const AuthFailure.weakPassword([]);
case 'same_password':
return const AuthFailure.samePassword();
case 'otp_expired':
return const AuthFailure.linkInvalidOrExpired();
case 'email_address_invalid' || 'validation_failed':
return const AuthFailure.invalidEmail();
}
if (error.statusCode == '429') {
return const AuthFailure.rateLimited();
}
if (operation == AuthOperation.verifyLink && error.statusCode == '403') {
return const AuthFailure.linkInvalidOrExpired();
}
return const AuthFailure.unexpected();
}
List<WeakPasswordReason> _weakReasons(List<String> reasons) => [
for (final reason in reasons)
switch (reason) {
'length' => WeakPasswordReason.length,
'characters' => WeakPasswordReason.characters,
'pwned' => WeakPasswordReason.pwned,
_ => WeakPasswordReason.unknown,
},
];
/// Liest den Grund aus der Trigger-Meldung (Body des HTTP-500). Best effort,
/// das Format ist nicht zugesichert.
RegistrationRejectionReason registrationRejectionReasonFrom(String message) {
if (message.contains('display_name_invalid')) {
return RegistrationRejectionReason.displayName;
}
if (message.contains('age_confirmation_required')) {
return RegistrationRejectionReason.ageConfirmation;
}
if (message.contains('terms_acceptance_required')) {
return RegistrationRejectionReason.termsAcceptance;
}
if (message.contains('terms_version_required')) {
return RegistrationRejectionReason.termsVersion;
}
return RegistrationRejectionReason.unknown;
}
/// Rohe Transportfehler (`SocketException`, `ClientException`, …). `dart:io`
/// und `package:http` werden bewusst nicht importiert (Web-Build, Lint);
/// `gotrue` verpackt Transportfehler normalerweise schon selbst.
bool _isTransportError(Object error) => switch (error.runtimeType.toString()) {
'SocketException' ||
'ClientException' ||
'HandshakeException' ||
'TlsException' => true,
_ => false,
};

View file

@ -0,0 +1,47 @@
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
// `AuthUser` heißt auch in supabase_flutter; hier ist die Domänenklasse gemeint.
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
/// Bildet `AuthChangeEvent`s auf [AuthStatus] ab (Spec #1 § 5.5).
///
/// Abweichung von der Wortlaut-Zuordnung in § 5.5: Die Recovery bleibt
/// bestehen, bis `userUpdated` (Passwort geändert) oder `signedOut` eintrifft.
/// Ein `tokenRefreshed` oder `signedIn` während der Recovery hebt den Guard
/// nicht auf. Der Zustand lebt nur im Prozess (Spec #1 § 5.4, R12): Aus dem
/// `amr`-Claim lässt er sich nicht ableiten.
class AuthStatusTracker {
bool _recovery = false;
/// Status für [session] unter Berücksichtigung der laufenden Recovery.
AuthStatus statusFor(Session? session) {
final user = session?.user;
if (user == null) {
return const AuthStatus.signedOut();
}
final authUser = AuthUser(
id: user.id,
email: user.email,
emailConfirmed: user.emailConfirmedAt != null,
);
return _recovery
? AuthStatus.passwordRecovery(authUser)
: AuthStatus.signedIn(authUser);
}
/// Verarbeitet ein Ereignis und liefert den neuen Status.
AuthStatus handle(AuthChangeEvent event, Session? session) {
switch (event) {
case AuthChangeEvent.passwordRecovery:
_recovery = session != null;
case AuthChangeEvent.signedOut || AuthChangeEvent.userUpdated:
_recovery = false;
default:
break;
}
if (session == null) {
_recovery = false;
}
return statusFor(session);
}
}

View file

@ -0,0 +1,163 @@
import 'dart:async';
import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
import 'package:shuffle/features/auth/data/auth_status_tracker.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
/// [AuthRepository] gegen Supabase Auth (GoTrue).
///
/// Logs enthalten weder E-Mail-Adressen noch Passwörter noch Tokens, nur
/// Operation, Fehlerart, HTTP-Status und Fehlercode.
class SupabaseAuthRepository implements AuthRepository {
SupabaseAuthRepository(this._auth) {
_lastStatus = _tracker.statusFor(_auth.currentSession);
_subscription = _auth.onAuthStateChange.listen(
_onAuthState,
onError: (Object error, StackTrace stackTrace) {
_log.warning('auth state stream error: ${error.runtimeType}');
},
);
}
final GoTrueClient _auth;
final _tracker = AuthStatusTracker();
final _changes = StreamController<AuthStatus>.broadcast(sync: true);
final _log = AppLogger('AuthRepository');
late AuthStatus _lastStatus;
StreamSubscription<AuthState>? _subscription;
void _onAuthState(AuthState state) {
final next = _tracker.handle(state.event, state.session);
if (next != _lastStatus) {
_lastStatus = next;
_changes.add(next);
}
}
/// Beendet das Abonnieren der Auth-Ereignisse.
Future<void> dispose() async {
await _subscription?.cancel();
_subscription = null;
await _changes.close();
}
@override
AuthStatus get currentStatus => _lastStatus;
@override
Stream<AuthStatus> watchStatus() {
// ignore: close_sinks, wird vom Abonnenten über cancel geschlossen.
late StreamController<AuthStatus> controller;
StreamSubscription<AuthStatus>? inner;
controller = StreamController<AuthStatus>(
onListen: () {
controller.add(_lastStatus);
inner = _changes.stream.listen(controller.add);
},
onCancel: () => inner?.cancel(),
);
return controller.stream;
}
@override
Future<Result<void, AuthFailure>> signUp(RegistrationInput input) => _guard(
AuthOperation.signUp,
() => _auth.signUp(
email: input.email.trim(),
password: input.password,
data: <String, dynamic>{
'display_name': input.displayName.trim(),
// JSON-Booleans: Der Trigger vergleicht `meta ->> '…'` mit 'true'.
'age_confirmed': input.ageConfirmed,
'terms_accepted': input.termsAccepted,
'terms_version': input.termsVersion,
'locale': input.locale,
},
),
);
@override
Future<Result<void, AuthFailure>> resendConfirmation(String email) => _guard(
AuthOperation.resend,
() => _auth.resend(type: OtpType.signup, email: email.trim()),
);
@override
Future<Result<void, AuthFailure>> signIn(String email, String password) =>
_guard(
AuthOperation.signIn,
() => _auth.signInWithPassword(email: email.trim(), password: password),
);
@override
Future<Result<void, AuthFailure>> requestPasswordReset(String email) =>
_guard(
AuthOperation.requestPasswordReset,
() => _auth.resetPasswordForEmail(email.trim()),
);
@override
Future<Result<void, AuthFailure>> verifyEmailLink(
String tokenHash,
EmailLinkType type,
) => _guard(AuthOperation.verifyLink, () async {
final response = await _auth.verifyOTP(
tokenHash: tokenHash,
type: switch (type) {
EmailLinkType.confirmation => OtpType.email,
EmailLinkType.recovery => OtpType.recovery,
},
);
if (response.session == null) {
throw StateError('verifyOTP returned no session');
}
});
@override
Future<Result<void, AuthFailure>> setNewPassword(String password) => _guard(
AuthOperation.updatePassword,
() => _auth.updateUser(UserAttributes(password: password)),
);
@override
Future<void> signOut() async {
try {
await _auth.signOut();
} on Object catch (error) {
_log.warning('signOut failed: ${error.runtimeType}');
}
}
Future<Result<void, AuthFailure>> _guard(
AuthOperation operation,
Future<void> Function() action,
) async {
try {
await action();
return const Result.success(null);
} on Object catch (error) {
final failure = mapAuthError(error, operation);
_logFailure(operation, error, failure);
return Result.failure(failure);
}
}
void _logFailure(AuthOperation operation, Object error, AuthFailure failure) {
final status = error is AuthException ? error.statusCode : null;
final code = error is AuthException ? error.code : null;
final reason = failure is RegistrationRejected
? ' reason=${failure.reason.name}'
: '';
_log.warning(
'${operation.name} failed: ${failure.runtimeType} '
'error=${error.runtimeType} status=$status code=$code$reason',
);
}
}

View file

@ -0,0 +1,43 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_failure.freezed.dart';
/// Grund einer abgelehnten Passwortstärke (`weak_password.reasons`).
enum WeakPasswordReason { length, characters, pwned, unknown }
/// Grund einer serverseitig abgelehnten Registrierung (Trigger-Meldung).
///
/// Nur zum Loggen und für Zusatzhinweise: Das Format der Meldung ist nicht
/// zugesichert (Spec #1 § 4.4), unbekannte Meldungen ergeben [unknown].
enum RegistrationRejectionReason {
displayName,
ageConfirmation,
termsAcceptance,
termsVersion,
unknown,
}
/// Fachliche Fehler der Auth-Abläufe (Mapping: Spec #1 § 4.4).
@freezed
sealed class AuthFailure with _$AuthFailure {
const factory AuthFailure.invalidCredentials() = InvalidCredentials;
const factory AuthFailure.emailNotConfirmed() = EmailNotConfirmed;
/// `over_email_send_rate_limit`. Wo die Adresse frei eingegeben wird, wird
/// das wie Erfolg behandelt (F12), siehe `resendFailureFor`.
const factory AuthFailure.emailSendLimited() = EmailSendLimited;
/// `over_request_rate_limit` und sonstiges HTTP 429.
const factory AuthFailure.rateLimited() = RateLimited;
const factory AuthFailure.weakPassword(List<WeakPasswordReason> reasons) =
WeakPassword;
const factory AuthFailure.samePassword() = SamePassword;
const factory AuthFailure.linkInvalidOrExpired() = LinkInvalidOrExpired;
const factory AuthFailure.invalidEmail() = InvalidEmail;
const factory AuthFailure.registrationRejected({
@Default(RegistrationRejectionReason.unknown)
RegistrationRejectionReason reason,
}) = RegistrationRejected;
const factory AuthFailure.network() = NetworkFailure;
const factory AuthFailure.unexpected() = Unexpected;
}

View file

@ -0,0 +1,659 @@
// 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 'auth_failure.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AuthFailure {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthFailure);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure()';
}
}
/// @nodoc
class $AuthFailureCopyWith<$Res> {
$AuthFailureCopyWith(AuthFailure _, $Res Function(AuthFailure) __);
}
/// Adds pattern-matching-related methods to [AuthFailure].
extension AuthFailurePatterns on AuthFailure {
/// 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( InvalidCredentials value)? invalidCredentials,TResult Function( EmailNotConfirmed value)? emailNotConfirmed,TResult Function( EmailSendLimited value)? emailSendLimited,TResult Function( RateLimited value)? rateLimited,TResult Function( WeakPassword value)? weakPassword,TResult Function( SamePassword value)? samePassword,TResult Function( LinkInvalidOrExpired value)? linkInvalidOrExpired,TResult Function( InvalidEmail value)? invalidEmail,TResult Function( RegistrationRejected value)? registrationRejected,TResult Function( NetworkFailure value)? network,TResult Function( Unexpected value)? unexpected,required TResult orElse(),}){
final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials(_that);case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed(_that);case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited(_that);case RateLimited() when rateLimited != null:
return rateLimited(_that);case WeakPassword() when weakPassword != null:
return weakPassword(_that);case SamePassword() when samePassword != null:
return samePassword(_that);case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired(_that);case InvalidEmail() when invalidEmail != null:
return invalidEmail(_that);case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that);case NetworkFailure() when network != null:
return network(_that);case Unexpected() when unexpected != null:
return unexpected(_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?>({required TResult Function( InvalidCredentials value) invalidCredentials,required TResult Function( EmailNotConfirmed value) emailNotConfirmed,required TResult Function( EmailSendLimited value) emailSendLimited,required TResult Function( RateLimited value) rateLimited,required TResult Function( WeakPassword value) weakPassword,required TResult Function( SamePassword value) samePassword,required TResult Function( LinkInvalidOrExpired value) linkInvalidOrExpired,required TResult Function( InvalidEmail value) invalidEmail,required TResult Function( RegistrationRejected value) registrationRejected,required TResult Function( NetworkFailure value) network,required TResult Function( Unexpected value) unexpected,}){
final _that = this;
switch (_that) {
case InvalidCredentials():
return invalidCredentials(_that);case EmailNotConfirmed():
return emailNotConfirmed(_that);case EmailSendLimited():
return emailSendLimited(_that);case RateLimited():
return rateLimited(_that);case WeakPassword():
return weakPassword(_that);case SamePassword():
return samePassword(_that);case LinkInvalidOrExpired():
return linkInvalidOrExpired(_that);case InvalidEmail():
return invalidEmail(_that);case RegistrationRejected():
return registrationRejected(_that);case NetworkFailure():
return network(_that);case Unexpected():
return unexpected(_that);}
}
/// 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( InvalidCredentials value)? invalidCredentials,TResult? Function( EmailNotConfirmed value)? emailNotConfirmed,TResult? Function( EmailSendLimited value)? emailSendLimited,TResult? Function( RateLimited value)? rateLimited,TResult? Function( WeakPassword value)? weakPassword,TResult? Function( SamePassword value)? samePassword,TResult? Function( LinkInvalidOrExpired value)? linkInvalidOrExpired,TResult? Function( InvalidEmail value)? invalidEmail,TResult? Function( RegistrationRejected value)? registrationRejected,TResult? Function( NetworkFailure value)? network,TResult? Function( Unexpected value)? unexpected,}){
final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials(_that);case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed(_that);case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited(_that);case RateLimited() when rateLimited != null:
return rateLimited(_that);case WeakPassword() when weakPassword != null:
return weakPassword(_that);case SamePassword() when samePassword != null:
return samePassword(_that);case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired(_that);case InvalidEmail() when invalidEmail != null:
return invalidEmail(_that);case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that);case NetworkFailure() when network != null:
return network(_that);case Unexpected() when unexpected != null:
return unexpected(_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()? invalidCredentials,TResult Function()? emailNotConfirmed,TResult Function()? emailSendLimited,TResult Function()? rateLimited,TResult Function( List<WeakPasswordReason> reasons)? weakPassword,TResult Function()? samePassword,TResult Function()? linkInvalidOrExpired,TResult Function()? invalidEmail,TResult Function( RegistrationRejectionReason reason)? registrationRejected,TResult Function()? network,TResult Function()? unexpected,required TResult orElse(),}) {final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials();case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed();case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited();case RateLimited() when rateLimited != null:
return rateLimited();case WeakPassword() when weakPassword != null:
return weakPassword(_that.reasons);case SamePassword() when samePassword != null:
return samePassword();case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired();case InvalidEmail() when invalidEmail != null:
return invalidEmail();case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that.reason);case NetworkFailure() when network != null:
return network();case Unexpected() when unexpected != null:
return unexpected();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?>({required TResult Function() invalidCredentials,required TResult Function() emailNotConfirmed,required TResult Function() emailSendLimited,required TResult Function() rateLimited,required TResult Function( List<WeakPasswordReason> reasons) weakPassword,required TResult Function() samePassword,required TResult Function() linkInvalidOrExpired,required TResult Function() invalidEmail,required TResult Function( RegistrationRejectionReason reason) registrationRejected,required TResult Function() network,required TResult Function() unexpected,}) {final _that = this;
switch (_that) {
case InvalidCredentials():
return invalidCredentials();case EmailNotConfirmed():
return emailNotConfirmed();case EmailSendLimited():
return emailSendLimited();case RateLimited():
return rateLimited();case WeakPassword():
return weakPassword(_that.reasons);case SamePassword():
return samePassword();case LinkInvalidOrExpired():
return linkInvalidOrExpired();case InvalidEmail():
return invalidEmail();case RegistrationRejected():
return registrationRejected(_that.reason);case NetworkFailure():
return network();case Unexpected():
return unexpected();}
}
/// 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()? invalidCredentials,TResult? Function()? emailNotConfirmed,TResult? Function()? emailSendLimited,TResult? Function()? rateLimited,TResult? Function( List<WeakPasswordReason> reasons)? weakPassword,TResult? Function()? samePassword,TResult? Function()? linkInvalidOrExpired,TResult? Function()? invalidEmail,TResult? Function( RegistrationRejectionReason reason)? registrationRejected,TResult? Function()? network,TResult? Function()? unexpected,}) {final _that = this;
switch (_that) {
case InvalidCredentials() when invalidCredentials != null:
return invalidCredentials();case EmailNotConfirmed() when emailNotConfirmed != null:
return emailNotConfirmed();case EmailSendLimited() when emailSendLimited != null:
return emailSendLimited();case RateLimited() when rateLimited != null:
return rateLimited();case WeakPassword() when weakPassword != null:
return weakPassword(_that.reasons);case SamePassword() when samePassword != null:
return samePassword();case LinkInvalidOrExpired() when linkInvalidOrExpired != null:
return linkInvalidOrExpired();case InvalidEmail() when invalidEmail != null:
return invalidEmail();case RegistrationRejected() when registrationRejected != null:
return registrationRejected(_that.reason);case NetworkFailure() when network != null:
return network();case Unexpected() when unexpected != null:
return unexpected();case _:
return null;
}
}
}
/// @nodoc
class InvalidCredentials implements AuthFailure {
const InvalidCredentials();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is InvalidCredentials);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.invalidCredentials()';
}
}
/// @nodoc
class EmailNotConfirmed implements AuthFailure {
const EmailNotConfirmed();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailNotConfirmed);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.emailNotConfirmed()';
}
}
/// @nodoc
class EmailSendLimited implements AuthFailure {
const EmailSendLimited();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is EmailSendLimited);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.emailSendLimited()';
}
}
/// @nodoc
class RateLimited implements AuthFailure {
const RateLimited();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RateLimited);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.rateLimited()';
}
}
/// @nodoc
class WeakPassword implements AuthFailure {
const WeakPassword( List<WeakPasswordReason> reasons): _reasons = reasons;
final List<WeakPasswordReason> _reasons;
List<WeakPasswordReason> get reasons {
if (_reasons is EqualUnmodifiableListView) return _reasons;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_reasons);
}
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$WeakPasswordCopyWith<WeakPassword> get copyWith => _$WeakPasswordCopyWithImpl<WeakPassword>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is WeakPassword&&const DeepCollectionEquality().equals(other.reasons, _reasons));
}
@override
int get hashCode {
return Object.hash(runtimeType,const DeepCollectionEquality().hash(_reasons));
}
@override
String toString() {
return 'AuthFailure.weakPassword(reasons: $reasons)';
}
}
/// @nodoc
abstract mixin class $WeakPasswordCopyWith<$Res> implements $AuthFailureCopyWith<$Res> {
factory $WeakPasswordCopyWith(WeakPassword value, $Res Function(WeakPassword) _then) = _$WeakPasswordCopyWithImpl;
@useResult
$Res call({
List<WeakPasswordReason> reasons
});
}
/// @nodoc
class _$WeakPasswordCopyWithImpl<$Res>
implements $WeakPasswordCopyWith<$Res> {
_$WeakPasswordCopyWithImpl(this._self, this._then);
final WeakPassword _self;
final $Res Function(WeakPassword) _then;
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? reasons = null,}) {
return _then(WeakPassword(
null == reasons ? _self._reasons : reasons // ignore: cast_nullable_to_non_nullable
as List<WeakPasswordReason>,
));
}
}
/// @nodoc
class SamePassword implements AuthFailure {
const SamePassword();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SamePassword);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.samePassword()';
}
}
/// @nodoc
class LinkInvalidOrExpired implements AuthFailure {
const LinkInvalidOrExpired();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is LinkInvalidOrExpired);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.linkInvalidOrExpired()';
}
}
/// @nodoc
class InvalidEmail implements AuthFailure {
const InvalidEmail();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is InvalidEmail);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.invalidEmail()';
}
}
/// @nodoc
class RegistrationRejected implements AuthFailure {
const RegistrationRejected({this.reason = RegistrationRejectionReason.unknown});
@JsonKey() final RegistrationRejectionReason reason;
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RegistrationRejectedCopyWith<RegistrationRejected> get copyWith => _$RegistrationRejectedCopyWithImpl<RegistrationRejected>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is RegistrationRejected&&(identical(other.reason, reason) || other.reason == reason));
}
@override
int get hashCode {
return Object.hash(runtimeType,reason);
}
@override
String toString() {
return 'AuthFailure.registrationRejected(reason: $reason)';
}
}
/// @nodoc
abstract mixin class $RegistrationRejectedCopyWith<$Res> implements $AuthFailureCopyWith<$Res> {
factory $RegistrationRejectedCopyWith(RegistrationRejected value, $Res Function(RegistrationRejected) _then) = _$RegistrationRejectedCopyWithImpl;
@useResult
$Res call({
RegistrationRejectionReason reason
});
}
/// @nodoc
class _$RegistrationRejectedCopyWithImpl<$Res>
implements $RegistrationRejectedCopyWith<$Res> {
_$RegistrationRejectedCopyWithImpl(this._self, this._then);
final RegistrationRejected _self;
final $Res Function(RegistrationRejected) _then;
/// Create a copy of AuthFailure
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? reason = null,}) {
return _then(RegistrationRejected(
reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
as RegistrationRejectionReason,
));
}
}
/// @nodoc
class NetworkFailure implements AuthFailure {
const NetworkFailure();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is NetworkFailure);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.network()';
}
}
/// @nodoc
class Unexpected implements AuthFailure {
const Unexpected();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is Unexpected);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthFailure.unexpected()';
}
}
// dart format on

View file

@ -0,0 +1,35 @@
import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
/// Zugriff auf die Anmeldung (Implementierung: Supabase/GoTrue).
abstract interface class AuthRepository {
/// Aktueller Status gefolgt von allen Änderungen.
Stream<AuthStatus> watchStatus();
/// Aktueller Status, synchron (für Router-Redirects).
AuthStatus get currentStatus;
/// Registriert ein Konto. Jede 200-Antwort ist Erfolg; ob die Adresse schon
/// existiert, wird nicht ausgewertet (KTO-01 Kriterium 4).
Future<Result<void, AuthFailure>> signUp(RegistrationInput input);
Future<Result<void, AuthFailure>> resendConfirmation(String email);
Future<Result<void, AuthFailure>> signIn(String email, String password);
Future<Result<void, AuthFailure>> requestPasswordReset(String email);
Future<Result<void, AuthFailure>> verifyEmailLink(
String tokenHash,
EmailLinkType type,
);
Future<Result<void, AuthFailure>> setNewPassword(String password);
/// Meldet lokal ab (u. a. beim Abbruch der Recovery). Fehler werden nur
/// geloggt.
Future<void> signOut();
}

View file

@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
part 'auth_status.freezed.dart';
/// Anmeldestatus der App.
///
/// [AuthStatus.passwordRecovery] gilt nur im laufenden Prozess: Eine
/// Recovery-Sitzung ist nach einem Neustart nicht mehr von einer normalen
/// Anmeldung zu unterscheiden (Spec #1 § 5.4, Risiko R12).
@freezed
sealed class AuthStatus with _$AuthStatus {
const AuthStatus._();
const factory AuthStatus.signedOut() = SignedOut;
const factory AuthStatus.signedIn(AuthUser user) = SignedIn;
const factory AuthStatus.passwordRecovery(AuthUser user) = PasswordRecovery;
bool get isSignedOut => this is SignedOut;
bool get isPasswordRecovery => this is PasswordRecovery;
}

View file

@ -0,0 +1,367 @@
// 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 'auth_status.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AuthStatus {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthStatus);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthStatus()';
}
}
/// @nodoc
class $AuthStatusCopyWith<$Res> {
$AuthStatusCopyWith(AuthStatus _, $Res Function(AuthStatus) __);
}
/// Adds pattern-matching-related methods to [AuthStatus].
extension AuthStatusPatterns on AuthStatus {
/// 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( SignedOut value)? signedOut,TResult Function( SignedIn value)? signedIn,TResult Function( PasswordRecovery value)? passwordRecovery,required TResult orElse(),}){
final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut(_that);case SignedIn() when signedIn != null:
return signedIn(_that);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_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?>({required TResult Function( SignedOut value) signedOut,required TResult Function( SignedIn value) signedIn,required TResult Function( PasswordRecovery value) passwordRecovery,}){
final _that = this;
switch (_that) {
case SignedOut():
return signedOut(_that);case SignedIn():
return signedIn(_that);case PasswordRecovery():
return passwordRecovery(_that);}
}
/// 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( SignedOut value)? signedOut,TResult? Function( SignedIn value)? signedIn,TResult? Function( PasswordRecovery value)? passwordRecovery,}){
final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut(_that);case SignedIn() when signedIn != null:
return signedIn(_that);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_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()? signedOut,TResult Function( AuthUser user)? signedIn,TResult Function( AuthUser user)? passwordRecovery,required TResult orElse(),}) {final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut();case SignedIn() when signedIn != null:
return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_that.user);case _:
return 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?>({required TResult Function() signedOut,required TResult Function( AuthUser user) signedIn,required TResult Function( AuthUser user) passwordRecovery,}) {final _that = this;
switch (_that) {
case SignedOut():
return signedOut();case SignedIn():
return signedIn(_that.user);case PasswordRecovery():
return passwordRecovery(_that.user);}
}
/// 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()? signedOut,TResult? Function( AuthUser user)? signedIn,TResult? Function( AuthUser user)? passwordRecovery,}) {final _that = this;
switch (_that) {
case SignedOut() when signedOut != null:
return signedOut();case SignedIn() when signedIn != null:
return signedIn(_that.user);case PasswordRecovery() when passwordRecovery != null:
return passwordRecovery(_that.user);case _:
return null;
}
}
}
/// @nodoc
class SignedOut extends AuthStatus {
const SignedOut(): super._();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SignedOut);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'AuthStatus.signedOut()';
}
}
/// @nodoc
class SignedIn extends AuthStatus {
const SignedIn(this.user): super._();
final AuthUser user;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SignedInCopyWith<SignedIn> get copyWith => _$SignedInCopyWithImpl<SignedIn>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SignedIn&&(identical(other.user, user) || other.user == user));
}
@override
int get hashCode {
return Object.hash(runtimeType,user);
}
@override
String toString() {
return 'AuthStatus.signedIn(user: $user)';
}
}
/// @nodoc
abstract mixin class $SignedInCopyWith<$Res> implements $AuthStatusCopyWith<$Res> {
factory $SignedInCopyWith(SignedIn value, $Res Function(SignedIn) _then) = _$SignedInCopyWithImpl;
@useResult
$Res call({
AuthUser user
});
$AuthUserCopyWith<$Res> get user;
}
/// @nodoc
class _$SignedInCopyWithImpl<$Res>
implements $SignedInCopyWith<$Res> {
_$SignedInCopyWithImpl(this._self, this._then);
final SignedIn _self;
final $Res Function(SignedIn) _then;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? user = null,}) {
return _then(SignedIn(
null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
as AuthUser,
));
}
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthUserCopyWith<$Res> get user {
return $AuthUserCopyWith<$Res>(_self.user, (value) {
return _then(_self.copyWith(user: value));
});
}
}
/// @nodoc
class PasswordRecovery extends AuthStatus {
const PasswordRecovery(this.user): super._();
final AuthUser user;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PasswordRecoveryCopyWith<PasswordRecovery> get copyWith => _$PasswordRecoveryCopyWithImpl<PasswordRecovery>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordRecovery&&(identical(other.user, user) || other.user == user));
}
@override
int get hashCode {
return Object.hash(runtimeType,user);
}
@override
String toString() {
return 'AuthStatus.passwordRecovery(user: $user)';
}
}
/// @nodoc
abstract mixin class $PasswordRecoveryCopyWith<$Res> implements $AuthStatusCopyWith<$Res> {
factory $PasswordRecoveryCopyWith(PasswordRecovery value, $Res Function(PasswordRecovery) _then) = _$PasswordRecoveryCopyWithImpl;
@useResult
$Res call({
AuthUser user
});
$AuthUserCopyWith<$Res> get user;
}
/// @nodoc
class _$PasswordRecoveryCopyWithImpl<$Res>
implements $PasswordRecoveryCopyWith<$Res> {
_$PasswordRecoveryCopyWithImpl(this._self, this._then);
final PasswordRecovery _self;
final $Res Function(PasswordRecovery) _then;
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? user = null,}) {
return _then(PasswordRecovery(
null == user ? _self.user : user // ignore: cast_nullable_to_non_nullable
as AuthUser,
));
}
/// Create a copy of AuthStatus
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AuthUserCopyWith<$Res> get user {
return $AuthUserCopyWith<$Res>(_self.user, (value) {
return _then(_self.copyWith(user: value));
});
}
}
// dart format on

View file

@ -0,0 +1,13 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_user.freezed.dart';
/// Angemeldeter Nutzer, soweit die UI ihn braucht.
@freezed
abstract class AuthUser with _$AuthUser {
const factory AuthUser({
required String id,
required String? email,
required bool emailConfirmed,
}) = _AuthUser;
}

View file

@ -0,0 +1,285 @@
// 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 'auth_user.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$AuthUser {
String get id; String? get email; bool get emailConfirmed;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AuthUserCopyWith<AuthUser> get copyWith => _$AuthUserCopyWithImpl<AuthUser>(this as AuthUser, _$identity);
@override
bool operator ==(Object other) {
final _this = this as AuthUser;
return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthUser&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.emailConfirmed, _this.emailConfirmed) || other.emailConfirmed == _this.emailConfirmed));
}
@override
int get hashCode {
final _this = this as AuthUser;
return Object.hash(runtimeType,_this.id,_this.email,_this.emailConfirmed);
}
@override
String toString() {
final _this = this as AuthUser;
return 'AuthUser(id: ${_this.id}, email: ${_this.email}, emailConfirmed: ${_this.emailConfirmed})';
}
}
/// @nodoc
abstract mixin class $AuthUserCopyWith<$Res> {
factory $AuthUserCopyWith(AuthUser value, $Res Function(AuthUser) _then) = _$AuthUserCopyWithImpl;
@useResult
$Res call({
String id, String? email, bool emailConfirmed
});
}
/// @nodoc
class _$AuthUserCopyWithImpl<$Res>
implements $AuthUserCopyWith<$Res> {
_$AuthUserCopyWithImpl(this._self, this._then);
final AuthUser _self;
final $Res Function(AuthUser) _then;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? email = freezed,Object? emailConfirmed = null,}) {
return _then(AuthUser(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String?,emailConfirmed: null == emailConfirmed ? _self.emailConfirmed : emailConfirmed // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// Adds pattern-matching-related methods to [AuthUser].
extension AuthUserPatterns on AuthUser {
/// 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( _AuthUser value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _AuthUser() 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( _AuthUser value) $default,){
final _that = this;
switch (_that) {
case _AuthUser():
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( _AuthUser value)? $default,){
final _that = this;
switch (_that) {
case _AuthUser() 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 id, String? email, bool emailConfirmed)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _AuthUser() when $default != null:
return $default(_that.id,_that.email,_that.emailConfirmed);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 id, String? email, bool emailConfirmed) $default,) {final _that = this;
switch (_that) {
case _AuthUser():
return $default(_that.id,_that.email,_that.emailConfirmed);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 id, String? email, bool emailConfirmed)? $default,) {final _that = this;
switch (_that) {
case _AuthUser() when $default != null:
return $default(_that.id,_that.email,_that.emailConfirmed);case _:
return null;
}
}
}
/// @nodoc
class _AuthUser implements AuthUser {
const _AuthUser({required this.id, required this.email, required this.emailConfirmed});
@override final String id;
@override final String? email;
@override final bool emailConfirmed;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AuthUserCopyWith<_AuthUser> get copyWith => __$AuthUserCopyWithImpl<_AuthUser>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AuthUser&&(identical(other.id, id) || other.id == id)&&(identical(other.email, email) || other.email == email)&&(identical(other.emailConfirmed, emailConfirmed) || other.emailConfirmed == emailConfirmed));
}
@override
int get hashCode {
return Object.hash(runtimeType,id,email,emailConfirmed);
}
@override
String toString() {
return 'AuthUser(id: $id, email: $email, emailConfirmed: $emailConfirmed)';
}
}
/// @nodoc
abstract mixin class _$AuthUserCopyWith<$Res> implements $AuthUserCopyWith<$Res> {
factory _$AuthUserCopyWith(_AuthUser value, $Res Function(_AuthUser) _then) = __$AuthUserCopyWithImpl;
@override @useResult
$Res call({
String id, String? email, bool emailConfirmed
});
}
/// @nodoc
class __$AuthUserCopyWithImpl<$Res>
implements _$AuthUserCopyWith<$Res> {
__$AuthUserCopyWithImpl(this._self, this._then);
final _AuthUser _self;
final $Res Function(_AuthUser) _then;
/// Create a copy of AuthUser
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? email = freezed,Object? emailConfirmed = null,}) {
return _then(_AuthUser(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,email: freezed == email ? _self.email : email // ignore: cast_nullable_to_non_nullable
as String?,emailConfirmed: null == emailConfirmed ? _self.emailConfirmed : emailConfirmed // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View file

@ -0,0 +1,13 @@
/// Art des Mail-Links (`type` in `/auth/confirm?token_hash=…&type=…`).
enum EmailLinkType {
confirmation,
recovery;
/// Liest den `type`-Parameter. `email` und `signup` bedeuten Bestätigung
/// (beide akzeptiert, Spike B0 (b)). Alles andere: `null`.
static EmailLinkType? tryParse(String? value) => switch (value) {
'email' || 'signup' => EmailLinkType.confirmation,
'recovery' => EmailLinkType.recovery,
_ => null,
};
}

View file

@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'registration_input.freezed.dart';
/// Eingaben der Registrierung (Spec #1 § 5.2).
@freezed
abstract class RegistrationInput with _$RegistrationInput {
const factory RegistrationInput({
required String email,
required String password,
required String displayName,
required bool ageConfirmed,
required bool termsAccepted,
required String termsVersion,
/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache.
required String locale,
}) = _RegistrationInput;
}

View file

@ -0,0 +1,299 @@
// 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 'registration_input.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$RegistrationInput {
String get email; String get password; String get displayName; bool get ageConfirmed; bool get termsAccepted; String get termsVersion;/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache.
String get locale;
/// Create a copy of RegistrationInput
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$RegistrationInputCopyWith<RegistrationInput> get copyWith => _$RegistrationInputCopyWithImpl<RegistrationInput>(this as RegistrationInput, _$identity);
@override
bool operator ==(Object other) {
final _this = this as RegistrationInput;
return identical(this, other) || (other.runtimeType == runtimeType&&other is RegistrationInput&&(identical(other.email, _this.email) || other.email == _this.email)&&(identical(other.password, _this.password) || other.password == _this.password)&&(identical(other.displayName, _this.displayName) || other.displayName == _this.displayName)&&(identical(other.ageConfirmed, _this.ageConfirmed) || other.ageConfirmed == _this.ageConfirmed)&&(identical(other.termsAccepted, _this.termsAccepted) || other.termsAccepted == _this.termsAccepted)&&(identical(other.termsVersion, _this.termsVersion) || other.termsVersion == _this.termsVersion)&&(identical(other.locale, _this.locale) || other.locale == _this.locale));
}
@override
int get hashCode {
final _this = this as RegistrationInput;
return Object.hash(runtimeType,_this.email,_this.password,_this.displayName,_this.ageConfirmed,_this.termsAccepted,_this.termsVersion,_this.locale);
}
@override
String toString() {
final _this = this as RegistrationInput;
return 'RegistrationInput(email: ${_this.email}, password: ${_this.password}, displayName: ${_this.displayName}, ageConfirmed: ${_this.ageConfirmed}, termsAccepted: ${_this.termsAccepted}, termsVersion: ${_this.termsVersion}, locale: ${_this.locale})';
}
}
/// @nodoc
abstract mixin class $RegistrationInputCopyWith<$Res> {
factory $RegistrationInputCopyWith(RegistrationInput value, $Res Function(RegistrationInput) _then) = _$RegistrationInputCopyWithImpl;
@useResult
$Res call({
String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale
});
}
/// @nodoc
class _$RegistrationInputCopyWithImpl<$Res>
implements $RegistrationInputCopyWith<$Res> {
_$RegistrationInputCopyWithImpl(this._self, this._then);
final RegistrationInput _self;
final $Res Function(RegistrationInput) _then;
/// Create a copy of RegistrationInput
/// 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? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? termsVersion = null,Object? locale = null,}) {
return _then(RegistrationInput(
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,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable
as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable
as bool,termsVersion: null == termsVersion ? _self.termsVersion : termsVersion // ignore: cast_nullable_to_non_nullable
as String,locale: null == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [RegistrationInput].
extension RegistrationInputPatterns on RegistrationInput {
/// 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( _RegistrationInput value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _RegistrationInput() 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( _RegistrationInput value) $default,){
final _that = this;
switch (_that) {
case _RegistrationInput():
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( _RegistrationInput value)? $default,){
final _that = this;
switch (_that) {
case _RegistrationInput() 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, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _RegistrationInput() when $default != null:
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);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, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale) $default,) {final _that = this;
switch (_that) {
case _RegistrationInput():
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);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, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale)? $default,) {final _that = this;
switch (_that) {
case _RegistrationInput() when $default != null:
return $default(_that.email,_that.password,_that.displayName,_that.ageConfirmed,_that.termsAccepted,_that.termsVersion,_that.locale);case _:
return null;
}
}
}
/// @nodoc
class _RegistrationInput implements RegistrationInput {
const _RegistrationInput({required this.email, required this.password, required this.displayName, required this.ageConfirmed, required this.termsAccepted, required this.termsVersion, required this.locale});
@override final String email;
@override final String password;
@override final String displayName;
@override final bool ageConfirmed;
@override final bool termsAccepted;
@override final String termsVersion;
/// Aktive Oberflächensprache (`de` oder `en`), bestimmt die Mailsprache.
@override final String locale;
/// Create a copy of RegistrationInput
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$RegistrationInputCopyWith<_RegistrationInput> get copyWith => __$RegistrationInputCopyWithImpl<_RegistrationInput>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _RegistrationInput&&(identical(other.email, email) || other.email == email)&&(identical(other.password, password) || other.password == password)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.ageConfirmed, ageConfirmed) || other.ageConfirmed == ageConfirmed)&&(identical(other.termsAccepted, termsAccepted) || other.termsAccepted == termsAccepted)&&(identical(other.termsVersion, termsVersion) || other.termsVersion == termsVersion)&&(identical(other.locale, locale) || other.locale == locale));
}
@override
int get hashCode {
return Object.hash(runtimeType,email,password,displayName,ageConfirmed,termsAccepted,termsVersion,locale);
}
@override
String toString() {
return 'RegistrationInput(email: $email, password: $password, displayName: $displayName, ageConfirmed: $ageConfirmed, termsAccepted: $termsAccepted, termsVersion: $termsVersion, locale: $locale)';
}
}
/// @nodoc
abstract mixin class _$RegistrationInputCopyWith<$Res> implements $RegistrationInputCopyWith<$Res> {
factory _$RegistrationInputCopyWith(_RegistrationInput value, $Res Function(_RegistrationInput) _then) = __$RegistrationInputCopyWithImpl;
@override @useResult
$Res call({
String email, String password, String displayName, bool ageConfirmed, bool termsAccepted, String termsVersion, String locale
});
}
/// @nodoc
class __$RegistrationInputCopyWithImpl<$Res>
implements _$RegistrationInputCopyWith<$Res> {
__$RegistrationInputCopyWithImpl(this._self, this._then);
final _RegistrationInput _self;
final $Res Function(_RegistrationInput) _then;
/// Create a copy of RegistrationInput
/// 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? displayName = null,Object? ageConfirmed = null,Object? termsAccepted = null,Object? termsVersion = null,Object? locale = null,}) {
return _then(_RegistrationInput(
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,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,ageConfirmed: null == ageConfirmed ? _self.ageConfirmed : ageConfirmed // ignore: cast_nullable_to_non_nullable
as bool,termsAccepted: null == termsAccepted ? _self.termsAccepted : termsAccepted // ignore: cast_nullable_to_non_nullable
as bool,termsVersion: null == termsVersion ? _self.termsVersion : termsVersion // ignore: cast_nullable_to_non_nullable
as String,locale: null == locale ? _self.locale : locale // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View file

@ -0,0 +1,30 @@
import 'package:shuffle/features/auth/domain/auth_failure.dart';
/// Von wo „E-Mail erneut senden“ ausgelöst wird (Spec #1 § 4.4, F12).
enum ResendOrigin {
/// CheckEmailScreen: Die Adresse kann frei eingegeben sein.
checkEmail,
/// LoginScreen nach `email_not_confirmed`: Das Passwort war richtig.
login,
}
/// Wendet die Regel aus F12 für „E-Mail erneut senden“ an.
///
/// `null` heißt: wie Erfolg behandeln. Aus dem LoginScreen wird
/// `emailSendLimited` zum sichtbaren Drosselungshinweis ([RateLimited]).
AuthFailure? resendFailureFor(AuthFailure failure, ResendOrigin origin) {
if (failure is EmailSendLimited) {
return switch (origin) {
ResendOrigin.checkEmail => null,
ResendOrigin.login => const AuthFailure.rateLimited(),
};
}
return failure;
}
/// Registrierung und Passwort-Reset (freie Adresseingabe): `emailSendLimited`
/// zählt wie Erfolg (F12, KTO-01 Kriterium 4). Liefert `null` für „wie
/// Erfolg“, sonst den Fehler.
AuthFailure? openEmailInputFailureFor(AuthFailure failure) =>
failure is EmailSendLimited ? null : failure;

View file

@ -0,0 +1,20 @@
/// Liste häufiger Passwörter (Implementierung in `data/`).
abstract interface class CommonPasswordList {
/// `true`, wenn [password] auf der Liste steht. Vergleich ohne Groß- und
/// Kleinschreibung, nach Trim.
bool contains(String password);
}
/// Einfache In-Memory-Liste aus bereits normalisierten Einträgen
/// (Kleinschreibung, ohne Leerraum am Rand).
class SetCommonPasswordList implements CommonPasswordList {
const SetCommonPasswordList(this._entries);
final Set<String> _entries;
int get length => _entries.length;
@override
bool contains(String password) =>
_entries.contains(password.trim().toLowerCase());
}

View file

@ -0,0 +1,42 @@
/// Fehler des Anzeigenamens.
enum DisplayNameError {
empty,
tooLong,
/// Enthält U+200B (Zero Width Space). Die UI zeigt dieselbe Meldung wie für
/// einen ungültigen Namen (Spec #1 § 5.4), kein eigener Text.
invalidCharacter,
}
/// Regel für Anzeigenamen (KTO-01 Kriterium 10, KTO-02 Kriterium 1).
///
/// Identisch zum Server (`public.trim_ws` + `char_length`): Leerraum am Rand
/// wird entfernt (Dart `trim()` und `trim_ws` entfernen dieselben Zeichen,
/// auch NBSP und Tab). Danach 1 bis 30 Unicode-Codepoints, gezählt über
/// `runes`, nicht über `String.length` (UTF-16). U+200B wird nicht getrimmt,
/// sondern überall abgelehnt.
abstract final class DisplayNameRule {
static const maxLength = 30;
static const zeroWidthSpace = '​';
/// Der Wert, der gespeichert und gesendet wird.
static String normalize(String value) => value.trim();
/// Länge in Codepoints nach dem Trim (für den Zähler `n/30`).
static int length(String value) => normalize(value).runes.length;
static DisplayNameError? validate(String value) {
final name = normalize(value);
if (name.contains(zeroWidthSpace)) {
return DisplayNameError.invalidCharacter;
}
final count = name.runes.length;
if (count == 0) {
return DisplayNameError.empty;
}
if (count > maxLength) {
return DisplayNameError.tooLong;
}
return null;
}
}

View file

@ -0,0 +1,23 @@
/// Fehler der E-Mail-Adresse.
enum EmailError { empty, invalid }
/// Einfache Syntaxprüfung (Feinprüfung macht der Server): ein `@`, Text davor,
/// ein Punkt im Domänenteil, kein Leerraum.
abstract final class EmailFormat {
static const maxLength = 254;
static final _pattern = RegExp(r'^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$');
static String normalize(String value) => value.trim();
static EmailError? validate(String value) {
final email = normalize(value);
if (email.isEmpty) {
return EmailError.empty;
}
if (email.length > maxLength || !_pattern.hasMatch(email)) {
return EmailError.invalid;
}
return null;
}
}

View file

@ -0,0 +1,38 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
part 'password_policy.freezed.dart';
/// Fehler der Passwortregel.
@freezed
sealed class PasswordError with _$PasswordError {
/// Zu kurz; [missing] Zeichen fehlen noch.
const factory PasswordError.tooShort(int missing) = PasswordTooShort;
const factory PasswordError.common() = PasswordCommon;
}
/// Passwortregel (KTO-01 Kriterium 3): mindestens 10 Zeichen und nicht auf der
/// Liste häufiger Passwörter.
///
/// Die Länge zählt Unicode-Codepoints des unveränderten Passworts (Leerzeichen
/// sind gültige Zeichen und werden nicht entfernt). Nur der Listenvergleich
/// trimmt und ignoriert die Groß-/Kleinschreibung.
abstract final class PasswordPolicy {
static const minLength = 10;
/// Prüft [password]. Ist die Liste noch nicht geladen ([common] `null`),
/// läuft nur die Längenprüfung; vor dem Absenden mit Liste erneut prüfen.
static PasswordError? validate(
String password, {
CommonPasswordList? common,
}) {
final length = password.runes.length;
if (length < minLength) {
return PasswordError.tooShort(minLength - length);
}
if (common != null && common.contains(password)) {
return const PasswordError.common();
}
return null;
}
}

View file

@ -0,0 +1,275 @@
// 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 'password_policy.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$PasswordError {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordError);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'PasswordError()';
}
}
/// @nodoc
class $PasswordErrorCopyWith<$Res> {
$PasswordErrorCopyWith(PasswordError _, $Res Function(PasswordError) __);
}
/// Adds pattern-matching-related methods to [PasswordError].
extension PasswordErrorPatterns on PasswordError {
/// 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( PasswordTooShort value)? tooShort,TResult Function( PasswordCommon value)? common,required TResult orElse(),}){
final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that);case PasswordCommon() when common != null:
return common(_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?>({required TResult Function( PasswordTooShort value) tooShort,required TResult Function( PasswordCommon value) common,}){
final _that = this;
switch (_that) {
case PasswordTooShort():
return tooShort(_that);case PasswordCommon():
return common(_that);}
}
/// 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( PasswordTooShort value)? tooShort,TResult? Function( PasswordCommon value)? common,}){
final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that);case PasswordCommon() when common != null:
return common(_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( int missing)? tooShort,TResult Function()? common,required TResult orElse(),}) {final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that.missing);case PasswordCommon() when common != null:
return common();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?>({required TResult Function( int missing) tooShort,required TResult Function() common,}) {final _that = this;
switch (_that) {
case PasswordTooShort():
return tooShort(_that.missing);case PasswordCommon():
return common();}
}
/// 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( int missing)? tooShort,TResult? Function()? common,}) {final _that = this;
switch (_that) {
case PasswordTooShort() when tooShort != null:
return tooShort(_that.missing);case PasswordCommon() when common != null:
return common();case _:
return null;
}
}
}
/// @nodoc
class PasswordTooShort implements PasswordError {
const PasswordTooShort(this.missing);
final int missing;
/// Create a copy of PasswordError
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PasswordTooShortCopyWith<PasswordTooShort> get copyWith => _$PasswordTooShortCopyWithImpl<PasswordTooShort>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordTooShort&&(identical(other.missing, missing) || other.missing == missing));
}
@override
int get hashCode {
return Object.hash(runtimeType,missing);
}
@override
String toString() {
return 'PasswordError.tooShort(missing: $missing)';
}
}
/// @nodoc
abstract mixin class $PasswordTooShortCopyWith<$Res> implements $PasswordErrorCopyWith<$Res> {
factory $PasswordTooShortCopyWith(PasswordTooShort value, $Res Function(PasswordTooShort) _then) = _$PasswordTooShortCopyWithImpl;
@useResult
$Res call({
int missing
});
}
/// @nodoc
class _$PasswordTooShortCopyWithImpl<$Res>
implements $PasswordTooShortCopyWith<$Res> {
_$PasswordTooShortCopyWithImpl(this._self, this._then);
final PasswordTooShort _self;
final $Res Function(PasswordTooShort) _then;
/// Create a copy of PasswordError
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? missing = null,}) {
return _then(PasswordTooShort(
null == missing ? _self.missing : missing // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
class PasswordCommon implements PasswordError {
const PasswordCommon();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PasswordCommon);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'PasswordError.common()';
}
}
// dart format on

View file

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/routing/app_routes.dart';
/// Platzhalter für Auth-Seiten, bis A2 bis A4 die Screens liefern. Ohne
/// sichtbare Texte (keine hartkodierten UI-Strings).
class AuthRoutePlaceholder extends StatelessWidget {
const AuthRoutePlaceholder({required this.routeKey, super.key});
final String routeKey;
@override
Widget build(BuildContext context) =>
Scaffold(key: Key('auth_placeholder_$routeKey'), body: const SizedBox());
}
/// Routen der Auth-Seiten nach Spec #1 § 5.3. A2 bis A4 ersetzen die Builder.
final List<RouteBase> authRoutes = [
_placeholder(AppRoutes.login, 'login'),
GoRoute(
path: AppRoutes.register,
builder: (context, state) =>
const AuthRoutePlaceholder(routeKey: 'register'),
routes: [
GoRoute(
path: 'check-email',
builder: (context, state) =>
const AuthRoutePlaceholder(routeKey: 'check_email'),
),
],
),
_placeholder(AppRoutes.forgotPassword, 'forgot_password'),
_placeholder(AppRoutes.authConfirm, 'auth_confirm'),
_placeholder(AppRoutes.resetPassword, 'reset_password'),
];
GoRoute _placeholder(String path, String key) => GoRoute(
path: path,
builder: (context, state) => AuthRoutePlaceholder(routeKey: key),
);

View file

@ -0,0 +1,63 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/config/app_config_provider.dart';
import 'package:shuffle/core/l10n/app_locale.dart';
import 'package:shuffle/core/supabase/supabase_client_provider.dart';
import 'package:shuffle/features/auth/data/asset_common_password_list.dart';
import 'package:shuffle/features/auth/data/supabase_auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
part 'auth_providers.g.dart';
@Riverpod(keepAlive: true)
AuthRepository authRepository(Ref ref) {
final repository = SupabaseAuthRepository(
ref.watch(supabaseClientProvider).auth,
);
ref.onDispose(repository.dispose);
return repository;
}
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
@Riverpod(keepAlive: true)
Stream<AuthStatus> authStatus(Ref ref) =>
ref.watch(authRepositoryProvider).watchStatus();
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start.
@Riverpod(keepAlive: true)
Future<CommonPasswordList> commonPasswordList(Ref ref) =>
const AssetCommonPasswordList().load();
/// Baut die Registrierungseingabe mit `terms_version` aus der [AppConfig] und
/// der aktiven Oberflächensprache als `locale` (F5, F6, KTO-11 Kriterium 11).
typedef RegistrationInputFactory = RegistrationInput Function({
required String email,
required String password,
required String displayName,
required bool ageConfirmed,
required bool termsAccepted,
});
@riverpod
RegistrationInputFactory registrationInputFactory(Ref ref) {
final termsVersion = ref.watch(appConfigProvider).termsVersion;
final locale = ref.watch(appLocaleProvider).languageCode;
return ({
required email,
required password,
required displayName,
required ageConfirmed,
required termsAccepted,
}) => RegistrationInput(
email: email,
password: password,
displayName: displayName,
ageConfirmed: ageConfirmed,
termsAccepted: termsAccepted,
termsVersion: termsVersion,
locale: locale,
);
}

View file

@ -0,0 +1,192 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_providers.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(authRepository)
final authRepositoryProvider = AuthRepositoryProvider._();
final class AuthRepositoryProvider
extends $FunctionalProvider<AuthRepository, AuthRepository, AuthRepository>
with $Provider<AuthRepository> {
AuthRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authRepositoryHash();
@$internal
@override
$ProviderElement<AuthRepository> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
AuthRepository create(Ref ref) {
return authRepository(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AuthRepository value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AuthRepository>(value),
);
}
}
String _$authRepositoryHash() => r'a8f4563b2d07ee4f38024d50bed6e844adc95398';
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
@ProviderFor(authStatus)
final authStatusProvider = AuthStatusProvider._();
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
final class AuthStatusProvider
extends
$FunctionalProvider<
AsyncValue<AuthStatus>,
AuthStatus,
Stream<AuthStatus>
>
with $FutureModifier<AuthStatus>, $StreamProvider<AuthStatus> {
/// Anmeldestatus als Stream (zuerst der aktuelle Wert, dann Änderungen).
AuthStatusProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authStatusProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authStatusHash();
@$internal
@override
$StreamProviderElement<AuthStatus> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<AuthStatus> create(Ref ref) {
return authStatus(ref);
}
}
String _$authStatusHash() => r'4dc863db6fda2e17d84d0c3ed5c9bc1808630293';
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start.
@ProviderFor(commonPasswordList)
final commonPasswordListProvider = CommonPasswordListProvider._();
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start.
final class CommonPasswordListProvider
extends
$FunctionalProvider<
AsyncValue<CommonPasswordList>,
CommonPasswordList,
FutureOr<CommonPasswordList>
>
with
$FutureModifier<CommonPasswordList>,
$FutureProvider<CommonPasswordList> {
/// Liste häufiger Passwörter. Lazy: lädt erst beim ersten Lesen (Öffnen eines
/// Passwortfeldes), nicht beim App-Start.
CommonPasswordListProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'commonPasswordListProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$commonPasswordListHash();
@$internal
@override
$FutureProviderElement<CommonPasswordList> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<CommonPasswordList> create(Ref ref) {
return commonPasswordList(ref);
}
}
String _$commonPasswordListHash() =>
r'964e79d4dc152c38ca77a55919cef3c14e5d3533';
@ProviderFor(registrationInputFactory)
final registrationInputFactoryProvider = RegistrationInputFactoryProvider._();
final class RegistrationInputFactoryProvider
extends
$FunctionalProvider<
RegistrationInputFactory,
RegistrationInputFactory,
RegistrationInputFactory
>
with $Provider<RegistrationInputFactory> {
RegistrationInputFactoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'registrationInputFactoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$registrationInputFactoryHash();
@$internal
@override
$ProviderElement<RegistrationInputFactory> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
RegistrationInputFactory create(Ref ref) {
return registrationInputFactory(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(RegistrationInputFactory value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<RegistrationInputFactory>(value),
);
}
}
String _$registrationInputFactoryHash() =>
r'31d8018fd695e874cbde8247727de1d92e92766a';

View file

@ -0,0 +1,146 @@
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/core/routing/redirect_source.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
part 'auth_redirect.g.dart';
/// Seiten, die nur abgemeldete Nutzer sehen (angemeldet → Startseite bzw.
/// `from`).
const _guestOnlyPaths = {
AppRoutes.login,
AppRoutes.register,
AppRoutes.forgotPassword,
};
/// Pfade, die nie als `from`-Ziel zugelassen sind (Schleifenschutz).
const _neverFromPaths = {..._guestOnlyPaths, AppRoutes.resetPassword};
/// Redirect-Regeln der Auth-Story (Spec #1 § 5.3), rein und ohne Zustand.
///
/// [privatePaths] sind Pfadpräfixe privater Routen. Sie ist bewusst eine
/// explizite Liste (derzeit leer): Alles andere ist öffentlich, unbekannte
/// Pfade landen weiter auf der 404-Seite.
String? authRedirectFor(
AuthStatus status,
Uri location, {
Iterable<String> privatePaths = const [],
}) {
final path = location.path;
switch (status) {
case PasswordRecovery():
return path == AppRoutes.resetPassword ? null : AppRoutes.resetPassword;
case SignedIn():
if (path == AppRoutes.resetPassword) {
return AppRoutes.home;
}
if (_guestOnlyPaths.contains(path)) {
return safeFromPath(location.queryParameters[AppRoutes.fromParam]) ??
AppRoutes.home;
}
return null;
case SignedOut():
if (path == AppRoutes.resetPassword) {
return AppRoutes.login;
}
if (_isPrivate(path, privatePaths)) {
final target = location.hasQuery ? '$path?${location.query}' : path;
return Uri(
path: AppRoutes.login,
queryParameters: {AppRoutes.fromParam: target},
).toString();
}
return null;
}
}
bool _isPrivate(String path, Iterable<String> privatePaths) =>
privatePaths.any((prefix) => path == prefix || path.startsWith('$prefix/'));
/// Prüft den `from`-Parameter: nur interne Pfade (beginnen mit `/`, nicht mit
/// `//` oder `/\`), keine Auth-Seiten. Sonst `null`.
String? safeFromPath(String? from) {
if (from == null || !from.startsWith('/')) {
return null;
}
if (from.startsWith('//') || from.startsWith(r'/\')) {
return null;
}
final uri = Uri.tryParse(from);
if (uri == null || uri.hasScheme || uri.hasAuthority) {
return null;
}
if (_neverFromPaths.contains(uri.path) ||
uri.path.startsWith(AppRoutes.authConfirm)) {
return null;
}
return from;
}
/// [RedirectSource] mit Anmeldestatus für den Router.
class AuthRedirectSource implements RedirectSource {
AuthRedirectSource({required this.readStatus, this.privatePaths = const []});
final AuthStatus Function() readStatus;
final Iterable<String> privatePaths;
final _changes = ValueNotifier<int>(0);
@override
Listenable get changes => _changes;
/// Löst eine Neubewertung der Redirects aus.
void notifyStatusChanged() => _changes.value++;
void dispose() => _changes.dispose();
@override
String? redirect(GoRouterState state) =>
authRedirectFor(readStatus(), state.uri, privatePaths: privatePaths);
}
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
@Riverpod(keepAlive: true)
List<String> privateRoutePaths(Ref ref) => const [];
/// Aufräumen beim Abmelden (`ref.invalidate(...)` privater Provider).
typedef SignedOutHandler = void Function(Ref ref);
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
@Riverpod(keepAlive: true)
List<SignedOutHandler> signedOutHandlers(Ref ref) => const [];
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
@Riverpod(keepAlive: true)
AuthRedirectSource authRedirectSource(Ref ref) {
final source = AuthRedirectSource(
readStatus: () => ref.read(authRepositoryProvider).currentStatus,
privatePaths: ref.watch(privateRoutePathsProvider),
);
ref
..onDispose(source.dispose)
..listen(authStatusProvider, (previous, next) {
final before = previous?.value;
final after = next.value;
if (before != null && before is! SignedOut && after is SignedOut) {
for (final handler in ref.read(signedOutHandlersProvider)) {
handler(ref);
}
}
source.notifyStatusChanged();
});
return source;
}
/// Overrides, die die Auth-Redirects in den Router einhängen (in `main`).
List<Override> authOverrides() => [
redirectSourceProvider.overrideWith(
(ref) => ref.watch(authRedirectSourceProvider),
),
];

View file

@ -0,0 +1,172 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_redirect.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
@ProviderFor(privateRoutePaths)
final privateRoutePathsProvider = PrivateRoutePathsProvider._();
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
final class PrivateRoutePathsProvider
extends $FunctionalProvider<List<String>, List<String>, List<String>>
with $Provider<List<String>> {
/// Pfadpräfixe privater Routen. Spätere Stories überschreiben den Provider.
PrivateRoutePathsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'privateRoutePathsProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$privateRoutePathsHash();
@$internal
@override
$ProviderElement<List<String>> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
List<String> create(Ref ref) {
return privateRoutePaths(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<String> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<String>>(value),
);
}
}
String _$privateRoutePathsHash() => r'21992f8c0bb0719b440ddc40c118e14d79617c29';
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
@ProviderFor(signedOutHandlers)
final signedOutHandlersProvider = SignedOutHandlersProvider._();
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
final class SignedOutHandlersProvider
extends
$FunctionalProvider<
List<SignedOutHandler>,
List<SignedOutHandler>,
List<SignedOutHandler>
>
with $Provider<List<SignedOutHandler>> {
/// Zentraler `onSignedOut`-Hook. Spätere Stories überschreiben den Provider
/// und hängen ihre Handler an.
SignedOutHandlersProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'signedOutHandlersProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$signedOutHandlersHash();
@$internal
@override
$ProviderElement<List<SignedOutHandler>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<SignedOutHandler> create(Ref ref) {
return signedOutHandlers(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<SignedOutHandler> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<SignedOutHandler>>(value),
);
}
}
String _$signedOutHandlersHash() => r'731ddc5c0c4d0aec1d144773b7aae23ce86321c8';
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
@ProviderFor(authRedirectSource)
final authRedirectSourceProvider = AuthRedirectSourceProvider._();
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
final class AuthRedirectSourceProvider
extends
$FunctionalProvider<
AuthRedirectSource,
AuthRedirectSource,
AuthRedirectSource
>
with $Provider<AuthRedirectSource> {
/// Router-Quelle für Auth. Führt die [signedOutHandlers] aus, wenn der Status
/// von angemeldet (oder Recovery) zu abgemeldet wechselt. Der Start im
/// abgemeldeten Zustand löst nichts aus. Die Navigation zu `/login` ergibt sich
/// aus den Redirect-Regeln, sobald eine private Route offen ist.
AuthRedirectSourceProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authRedirectSourceProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authRedirectSourceHash();
@$internal
@override
$ProviderElement<AuthRedirectSource> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AuthRedirectSource create(Ref ref) {
return authRedirectSource(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AuthRedirectSource value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AuthRedirectSource>(value),
);
}
}
String _$authRedirectSourceHash() =>
r'c543c4be331cebf1f7e4865330293021265a336b';

View file

@ -5,6 +5,7 @@ import 'package:shuffle/core/config/app_config.dart';
import 'package:shuffle/core/error/global_error_handler.dart';
import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/core/routing/url_strategy.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
@ -19,5 +20,5 @@ Future<void> main() async {
publishableKey: config.supabaseAnonKey,
);
runApp(const ProviderScope(child: ShuffleApp()));
runApp(ProviderScope(overrides: authOverrides(), child: const ShuffleApp()));
}

View file

@ -37,3 +37,5 @@ dev_dependencies:
flutter:
uses-material-design: true
generate: true
assets:
- assets/security/common-passwords.txt

View file

@ -0,0 +1,264 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
AuthApiException _api(String code, {String status = '400'}) =>
AuthApiException('msg', statusCode: status, code: code);
const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}';
class SocketException implements Exception {}
class ClientException implements Exception {}
void main() {
group('Spec #1 § 4.4 error mapping', () {
test('KTO-01 Kriterium 7: invalid_credentials', () {
expect(
mapAuthError(_api('invalid_credentials'), AuthOperation.signIn),
const AuthFailure.invalidCredentials(),
);
});
test('KTO-01 Kriterium 6: email_not_confirmed', () {
expect(
mapAuthError(_api('email_not_confirmed'), AuthOperation.signIn),
const AuthFailure.emailNotConfirmed(),
);
});
test('KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited '
'for signUp, reset and resend', () {
for (final op in [
AuthOperation.signUp,
AuthOperation.requestPasswordReset,
AuthOperation.resend,
]) {
expect(
mapAuthError(_api('over_email_send_rate_limit', status: '429'), op),
const AuthFailure.emailSendLimited(),
reason: op.name,
);
}
});
test('over_email_send_rate_limit elsewhere is rateLimited', () {
expect(
mapAuthError(
_api('over_email_send_rate_limit', status: '429'),
AuthOperation.updatePassword,
),
const AuthFailure.rateLimited(),
);
});
test('KTO-01 Kriterium 7: over_request_rate_limit -> rateLimited', () {
expect(
mapAuthError(
_api('over_request_rate_limit', status: '429'),
AuthOperation.signIn,
),
const AuthFailure.rateLimited(),
);
});
test('any other HTTP 429 (also non-JSON body) -> rateLimited', () {
expect(
mapAuthError(
const AuthApiException('slow down', statusCode: '429'),
AuthOperation.signIn,
),
const AuthFailure.rateLimited(),
);
});
test('KTO-01 Kriterium 3: AuthWeakPasswordException with reasons', () {
expect(
mapAuthError(
AuthWeakPasswordException(
message: 'weak',
statusCode: '422',
reasons: const ['length', 'characters', 'pwned', 'other'],
),
AuthOperation.signUp,
),
const AuthFailure.weakPassword([
WeakPasswordReason.length,
WeakPasswordReason.characters,
WeakPasswordReason.pwned,
WeakPasswordReason.unknown,
]),
);
});
test('weak_password code on a plain API exception', () {
expect(
mapAuthError(
_api('weak_password', status: '422'),
AuthOperation.updatePassword,
),
const AuthFailure.weakPassword([]),
);
});
test('same_password', () {
expect(
mapAuthError(
_api('same_password', status: '422'),
AuthOperation.updatePassword,
),
const AuthFailure.samePassword(),
);
});
test('KTO-01 Kriterium 8: otp_expired -> linkInvalidOrExpired', () {
expect(
mapAuthError(
_api('otp_expired', status: '403'),
AuthOperation.verifyLink,
),
const AuthFailure.linkInvalidOrExpired(),
);
});
test('KTO-01 Kriterium 8: 403 without known code on verifyOTP', () {
expect(
mapAuthError(
const AuthApiException('invalid', statusCode: '403'),
AuthOperation.verifyLink,
),
const AuthFailure.linkInvalidOrExpired(),
);
expect(
mapAuthError(
const AuthApiException('forbidden', statusCode: '403'),
AuthOperation.signIn,
),
const AuthFailure.unexpected(),
);
});
test('email_address_invalid and validation_failed -> invalidEmail', () {
expect(
mapAuthError(_api('email_address_invalid'), AuthOperation.signUp),
const AuthFailure.invalidEmail(),
);
expect(
mapAuthError(
_api('validation_failed', status: '422'),
AuthOperation.requestPasswordReset,
),
const AuthFailure.invalidEmail(),
);
});
test('KTO-01 Kriterium 2, 10: HTTP 500 on signUp (trigger error) -> '
'registrationRejected, decided by status only', () {
final error = AuthRetryableFetchException(
message: _triggerBody,
statusCode: '500',
);
expect(error.code, isNull);
expect(
mapAuthError(error, AuthOperation.signUp),
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
);
});
test('HTTP 500 on signUp with unknown body keeps registrationRejected', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'oops', statusCode: '500'),
AuthOperation.signUp,
),
const AuthFailure.registrationRejected(),
);
});
test('trigger messages map to reasons', () {
expect(
registrationRejectionReasonFrom('age_confirmation_required'),
RegistrationRejectionReason.ageConfirmation,
);
expect(
registrationRejectionReasonFrom('terms_acceptance_required'),
RegistrationRejectionReason.termsAcceptance,
);
expect(
registrationRejectionReasonFrom('terms_version_required'),
RegistrationRejectionReason.termsVersion,
);
});
test('HTTP 500 outside signUp is a network failure', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: _triggerBody, statusCode: '500'),
AuthOperation.signIn,
),
const AuthFailure.network(),
);
});
test('other 5xx on signUp (gateway) -> network', () {
expect(
mapAuthError(
AuthRetryableFetchException(
message: 'bad gateway',
statusCode: '502',
),
AuthOperation.signUp,
),
const AuthFailure.network(),
);
});
test('Zustände Krit. 2: AuthRetryableFetchException without status', () {
expect(
mapAuthError(
AuthRetryableFetchException(message: 'Failed host lookup'),
AuthOperation.signIn,
),
const AuthFailure.network(),
);
});
test('TimeoutException and raw transport errors -> network', () {
expect(
mapAuthError(TimeoutException('t'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(SocketException(), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(ClientException(), AuthOperation.signIn),
const AuthFailure.network(),
);
});
test('everything else -> unexpected', () {
expect(
mapAuthError(StateError('x'), AuthOperation.signIn),
const AuthFailure.unexpected(),
);
expect(
mapAuthError(_api('something_new'), AuthOperation.signIn),
const AuthFailure.unexpected(),
);
expect(
mapAuthError(
AuthSessionMissingException(),
AuthOperation.updatePassword,
),
const AuthFailure.unexpected(),
);
});
});
}

View file

@ -0,0 +1,105 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/data/auth_status_tracker.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import 'auth_test_support.dart';
void main() {
group('KTO-01 Kriterium 5: AuthStatusTracker event mapping', () {
test('no session -> signedOut', () {
final tracker = AuthStatusTracker();
expect(tracker.statusFor(null), const AuthStatus.signedOut());
expect(
tracker.handle(AuthChangeEvent.initialSession, null),
const AuthStatus.signedOut(),
);
});
test(
'signedIn, tokenRefreshed, userUpdated, initialSession -> signedIn',
() {
final tracker = AuthStatusTracker();
const user = AuthUser(
id: 'user-1',
email: 'anna@example.com',
emailConfirmed: true,
);
for (final event in [
AuthChangeEvent.initialSession,
AuthChangeEvent.signedIn,
AuthChangeEvent.tokenRefreshed,
AuthChangeEvent.userUpdated,
]) {
expect(
tracker.handle(event, testSession()),
const AuthStatus.signedIn(user),
reason: event.name,
);
}
},
);
test('unconfirmed email is reflected in the user', () {
final status = AuthStatusTracker().handle(
AuthChangeEvent.signedIn,
testSession(user: testUser(confirmed: false)),
);
expect((status as SignedIn).user.emailConfirmed, isFalse);
});
test('signedOut -> signedOut', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.signedIn, testSession());
expect(
tracker.handle(AuthChangeEvent.signedOut, null),
const AuthStatus.signedOut(),
);
});
});
group('KTO-01 Kriterium 8: recovery guard (process only)', () {
test('passwordRecovery -> passwordRecovery', () {
final tracker = AuthStatusTracker();
expect(
tracker.handle(AuthChangeEvent.passwordRecovery, testSession()),
isA<PasswordRecovery>(),
);
});
test('guard survives tokenRefreshed and signedIn during recovery', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
expect(
tracker.handle(AuthChangeEvent.tokenRefreshed, testSession()),
isA<PasswordRecovery>(),
);
expect(
tracker.handle(AuthChangeEvent.signedIn, testSession()),
isA<PasswordRecovery>(),
);
expect(tracker.statusFor(testSession()), isA<PasswordRecovery>());
});
test('userUpdated (password changed) ends the recovery', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
expect(
tracker.handle(AuthChangeEvent.userUpdated, testSession()),
isA<SignedIn>(),
);
});
test('signedOut (abort) ends the recovery', () {
final tracker = AuthStatusTracker()
..handle(AuthChangeEvent.passwordRecovery, testSession());
tracker.handle(AuthChangeEvent.signedOut, null);
expect(tracker.statusFor(testSession()), isA<SignedIn>());
});
test('a fresh tracker never derives recovery from a session (reload)', () {
expect(AuthStatusTracker().statusFor(testSession()), isA<SignedIn>());
});
});
}

View file

@ -0,0 +1,24 @@
import 'package:supabase_flutter/supabase_flutter.dart';
User testUser({
String id = 'user-1',
String? email = 'anna@example.com',
bool confirmed = true,
List<UserIdentity>? identities,
}) => User(
id: id,
appMetadata: const {},
userMetadata: const {},
aud: 'authenticated',
createdAt: '2026-11-02T10:00:00Z',
email: email,
emailConfirmedAt: confirmed ? '2026-11-02T10:05:00Z' : null,
identities: identities,
);
Session testSession({User? user}) => Session(
accessToken: 'access',
tokenType: 'bearer',
refreshToken: 'refresh',
user: user ?? testUser(),
);

View file

@ -0,0 +1,80 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/data/asset_common_password_list.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('KTO-01 Kriterium 3: common password asset', () {
late List<String> lines;
setUpAll(() {
lines = File(AssetCommonPasswordList.assetPath)
.readAsStringSync()
.split('\n')
.where((l) => l.isNotEmpty)
.toList();
});
test('has a bounded size', () {
expect(lines.length, inInclusiveRange(5000, 15000));
expect(
File(AssetCommonPasswordList.assetPath).lengthSync(),
lessThan(200 * 1024),
);
});
test('contains only normalized entries of at least 10 code points', () {
for (final line in lines) {
expect(line.runes.length, greaterThanOrEqualTo(10), reason: line);
expect(line, line.trim(), reason: line);
expect(line, line.toLowerCase(), reason: line);
expect(line.startsWith('#'), isFalse, reason: line);
}
});
test('has no duplicates', () {
expect(lines.toSet().length, lines.length);
});
test('is declared as a bundle asset and loads lazily into a set', () async {
final list = await const AssetCommonPasswordList().load();
expect(list.contains('passwort1234'), isTrue);
expect(list.contains('Hallo12345'), isTrue);
expect(list.contains(' PASSWORT1234 '), isTrue);
expect(list.contains('korrekt-pferd-batterie-heftklammer'), isFalse);
});
test('fails loudly if the asset is missing', () async {
await expectLater(
AssetCommonPasswordList(bundle: _EmptyBundle()).load(),
throwsA(isA<FlutterError>()),
);
});
});
group('parseCommonPasswords', () {
test('normalizes lines and skips blanks and comments', () {
final list = parseCommonPasswords(
'# c\n\n ABCDEFGHIJ \r\nklmnopqrstu\n',
);
expect(list.contains('abcdefghij'), isTrue);
expect(list.contains('klmnopqrstu'), isTrue);
expect(list.contains('# c'), isFalse);
expect(list.contains(''), isFalse);
});
});
}
class _EmptyBundle extends CachingAssetBundle {
_EmptyBundle();
@override
Future<ByteData> load(String key) =>
Future.error(FlutterError('missing asset $key'));
}

View file

@ -0,0 +1,359 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/features/auth/data/supabase_auth_repository.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import '../../../helpers/fakes.dart';
import 'auth_test_support.dart';
const _input = RegistrationInput(
email: ' anna@example.com ',
password: ' geheimes-passwort ',
displayName: ' Anna ',
ageConfirmed: true,
termsAccepted: true,
termsVersion: 'test-1',
locale: 'de',
);
void main() {
late MockGoTrueClient auth;
late StreamController<AuthState> events;
late Session? currentSession;
late SupabaseAuthRepository repository;
setUpAll(() {
registerFallbackValue(OtpType.signup);
registerFallbackValue(UserAttributes());
});
setUp(() {
auth = MockGoTrueClient();
events = StreamController<AuthState>.broadcast(sync: true);
currentSession = null;
when(() => auth.currentSession).thenAnswer((_) => currentSession);
when(() => auth.onAuthStateChange).thenAnswer((_) => events.stream);
repository = SupabaseAuthRepository(auth);
});
tearDown(() async {
await repository.dispose();
await events.close();
});
group('KTO-01 Kriterium 1: signUp', () {
test('sends trimmed values and JSON-boolean metadata', () async {
Map<Symbol, dynamic>? args;
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenAnswer((invocation) async {
args = invocation.namedArguments;
return AuthResponse();
});
final result = await repository.signUp(_input);
expect(result.isSuccess, isTrue);
expect(args![#email], 'anna@example.com');
expect(args![#password], ' geheimes-passwort ', reason: 'untouched');
final captured = [args![#email], args![#password], args![#data]];
final data = captured[2] as Map<String, dynamic>;
expect(data, {
'display_name': 'Anna',
'age_confirmed': true,
'terms_accepted': true,
'terms_version': 'test-1',
'locale': 'de',
});
expect(data['age_confirmed'], isA<bool>());
expect(data['terms_accepted'], isA<bool>());
});
test(
'KTO-01 Kriterium 4: 200 with empty identities is plain success',
() async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenAnswer(
(_) async => AuthResponse(user: testUser(identities: const [])),
);
final result = await repository.signUp(_input);
expect(result.isSuccess, isTrue);
expect(result.errorOrNull, isNull);
},
);
test(
'KTO-01 Kriterium 10: trigger error (HTTP 500) is registrationRejected',
() async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenThrow(
AuthRetryableFetchException(
message: '{"code":"P0001","message":"display_name_invalid"}',
statusCode: '500',
),
);
final result = await repository.signUp(_input);
expect(
result.errorOrNull,
const AuthFailure.registrationRejected(
reason: RegistrationRejectionReason.displayName,
),
);
},
);
test(
'KTO-01 Kriterium 4: over_email_send_rate_limit -> emailSendLimited',
() async {
when(
() => auth.signUp(
email: any(named: 'email'),
password: any(named: 'password'),
data: any(named: 'data'),
),
).thenThrow(
const AuthApiException(
'email rate limit exceeded',
statusCode: '429',
code: 'over_email_send_rate_limit',
),
);
expect(
(await repository.signUp(_input)).errorOrNull,
const AuthFailure.emailSendLimited(),
);
},
);
});
group('other calls', () {
test(
'KTO-01 Kriterium 6: resend uses OtpType.signup and a trimmed email',
() async {
when(
() => auth.resend(
type: any(named: 'type'),
email: any(named: 'email'),
),
).thenAnswer((_) async => ResendResponse());
final result = await repository.resendConfirmation(
' anna@example.com ',
);
expect(result.isSuccess, isTrue);
verify(
() => auth.resend(type: OtpType.signup, email: 'anna@example.com'),
).called(1);
},
);
test('KTO-01 Kriterium 7: signIn maps invalid_credentials', () async {
when(
() => auth.signInWithPassword(
email: any(named: 'email'),
password: any(named: 'password'),
),
).thenThrow(
const AuthApiException(
'Invalid login credentials',
statusCode: '400',
code: 'invalid_credentials',
),
);
final result = await repository.signIn('anna@example.com', 'x');
expect(result.errorOrNull, const AuthFailure.invalidCredentials());
});
test('KTO-01 Kriterium 8: requestPasswordReset', () async {
when(() => auth.resetPasswordForEmail(any())).thenAnswer((_) async {});
final result = await repository.requestPasswordReset(' anna@example.com');
expect(result.isSuccess, isTrue);
verify(() => auth.resetPasswordForEmail('anna@example.com')).called(1);
});
test(
'KTO-01 Kriterium 8: recovery link sends only token hash and type',
() async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async => AuthResponse(session: testSession()));
final result = await repository.verifyEmailLink(
'hash',
EmailLinkType.recovery,
);
expect(result.isSuccess, isTrue);
verify(() => auth.verifyOTP(type: OtpType.recovery, tokenHash: 'hash'))
.called(1);
},
);
test('KTO-01 Kriterium 1: confirmation link uses OtpType.email', () async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async => AuthResponse(session: testSession()));
await repository.verifyEmailLink('hash', EmailLinkType.confirmation);
verify(() => auth.verifyOTP(type: OtpType.email, tokenHash: 'hash'))
.called(1);
});
test('KTO-01 Kriterium 8: used link -> linkInvalidOrExpired', () async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenThrow(
const AuthApiException(
'Email link is invalid or has expired',
statusCode: '403',
code: 'otp_expired',
),
);
final result = await repository.verifyEmailLink(
'hash',
EmailLinkType.recovery,
);
expect(result.errorOrNull, const AuthFailure.linkInvalidOrExpired());
});
test('verify without a session is unexpected', () async {
when(
() => auth.verifyOTP(
type: any(named: 'type'),
tokenHash: any(named: 'tokenHash'),
),
).thenAnswer((_) async => AuthResponse());
final result = await repository.verifyEmailLink(
'hash',
EmailLinkType.confirmation,
);
expect(result.errorOrNull, const AuthFailure.unexpected());
});
test('KTO-01 Kriterium 8: setNewPassword maps same_password', () async {
when(() => auth.updateUser(any())).thenThrow(
const AuthApiException(
'same',
statusCode: '422',
code: 'same_password',
),
);
final result = await repository.setNewPassword('neues-passwort');
expect(result.errorOrNull, const AuthFailure.samePassword());
});
test('setNewPassword sends the password attribute', () async {
when(() => auth.updateUser(any()))
.thenAnswer((_) async => UserResponse.fromJson(testUser().toJson()));
final result = await repository.setNewPassword('neues-passwort');
expect(result.isSuccess, isTrue);
final attrs =
verify(() => auth.updateUser(captureAny())).captured.single
as UserAttributes;
expect(attrs.password, 'neues-passwort');
});
test('signOut swallows errors', () async {
when(() => auth.signOut()).thenThrow(StateError('boom'));
await expectLater(repository.signOut(), completes);
});
});
group('KTO-01 Kriterium 5, 8: status', () {
test('currentStatus reflects the stored session at construction', () async {
currentSession = testSession();
final restored = SupabaseAuthRepository(auth);
addTearDown(restored.dispose);
expect(restored.currentStatus, isA<SignedIn>());
});
test('watchStatus emits the current value, then changes', () async {
final emitted = <AuthStatus>[];
final sub = repository.watchStatus().listen(emitted.add);
addTearDown(sub.cancel);
await Future<void>.delayed(Duration.zero);
events.add(AuthState(AuthChangeEvent.signedIn, testSession()));
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
events.add(AuthState(AuthChangeEvent.userUpdated, testSession()));
events.add(const AuthState(AuthChangeEvent.signedOut, null));
await Future<void>.delayed(Duration.zero);
expect(emitted.map((s) => s.runtimeType.toString()), [
'SignedOut',
'SignedIn',
'PasswordRecovery',
'SignedIn',
'SignedOut',
]);
});
test('identical statuses (token refresh) are not re-emitted', () async {
final emitted = <AuthStatus>[];
final sub = repository.watchStatus().listen(emitted.add);
addTearDown(sub.cancel);
events.add(AuthState(AuthChangeEvent.signedIn, testSession()));
events.add(AuthState(AuthChangeEvent.tokenRefreshed, testSession()));
await Future<void>.delayed(Duration.zero);
expect(emitted, hasLength(2));
});
test('recovery from a verified link is visible synchronously', () async {
events.add(AuthState(AuthChangeEvent.passwordRecovery, testSession()));
expect(repository.currentStatus, isA<PasswordRecovery>());
});
});
}

View file

@ -0,0 +1,65 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/email_link_type.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
void main() {
group('KTO-01 Kriterium 6: resendFailureFor (F12)', () {
const limited = AuthFailure.emailSendLimited();
test('check-email screen treats emailSendLimited as success', () {
expect(resendFailureFor(limited, ResendOrigin.checkEmail), isNull);
});
test('login screen shows the throttling hint', () {
expect(
resendFailureFor(limited, ResendOrigin.login),
const AuthFailure.rateLimited(),
);
});
test('other failures pass through unchanged', () {
for (final origin in ResendOrigin.values) {
expect(
resendFailureFor(const AuthFailure.rateLimited(), origin),
const AuthFailure.rateLimited(),
);
expect(
resendFailureFor(const AuthFailure.network(), origin),
const AuthFailure.network(),
);
}
});
});
group('KTO-01 Kriterium 4: openEmailInputFailureFor (F12)', () {
test('emailSendLimited counts as success', () {
expect(
openEmailInputFailureFor(const AuthFailure.emailSendLimited()),
isNull,
);
});
test('rateLimited stays visible', () {
expect(
openEmailInputFailureFor(const AuthFailure.rateLimited()),
const AuthFailure.rateLimited(),
);
});
});
group('KTO-01 Kriterium 8: EmailLinkType.tryParse', () {
test('maps email and signup to confirmation, recovery to recovery', () {
expect(EmailLinkType.tryParse('email'), EmailLinkType.confirmation);
expect(EmailLinkType.tryParse('signup'), EmailLinkType.confirmation);
expect(EmailLinkType.tryParse('recovery'), EmailLinkType.recovery);
});
test('rejects missing and unknown types', () {
expect(EmailLinkType.tryParse(null), isNull);
expect(EmailLinkType.tryParse(''), isNull);
expect(EmailLinkType.tryParse('magiclink'), isNull);
expect(EmailLinkType.tryParse('invite'), isNull);
});
});
}

View file

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
void main() {
group('KTO-01 Kriterium 10: DisplayNameRule', () {
test('accepts names of 1 to 30 code points', () {
expect(DisplayNameRule.validate('A'), isNull);
expect(DisplayNameRule.validate('a' * 30), isNull);
});
test('rejects empty and whitespace-only names', () {
expect(DisplayNameRule.validate(''), DisplayNameError.empty);
expect(DisplayNameRule.validate(' '), DisplayNameError.empty);
expect(DisplayNameRule.validate('\t \n'), DisplayNameError.empty);
});
test('rejects 31 code points', () {
expect(DisplayNameRule.validate('a' * 31), DisplayNameError.tooLong);
});
test('counts code points, not UTF-16 units (emoji)', () {
expect(DisplayNameRule.validate('😀' * 30), isNull);
expect(DisplayNameRule.validate('😀' * 31), DisplayNameError.tooLong);
expect(DisplayNameRule.length('😀' * 30), 30);
expect('😀' * 30, hasLength(60));
});
test('counts every code point of a ZWJ emoji sequence', () {
// Family emoji: 3 emoji + 2 ZWJ = 5 code points.
expect(DisplayNameRule.length('👨‍👩‍👧'), 5);
expect(DisplayNameRule.validate('👨‍👩‍👧' * 6), isNull);
expect(
DisplayNameRule.validate('👨‍👩‍👧' * 6 + 'x'),
DisplayNameError.tooLong,
);
});
test('trims NBSP, tab, BOM and ideographic space like the server', () {
expect(DisplayNameRule.normalize(' \tAnna '), 'Anna');
expect(DisplayNameRule.validate('  '), DisplayNameError.empty);
expect(DisplayNameRule.validate(' ${'a' * 30}\t'), isNull);
});
test('counts only the trimmed name', () {
expect(DisplayNameRule.validate(' ${'a' * 30} '), isNull);
expect(DisplayNameRule.length(' ab '), 2);
});
test('rejects U+200B anywhere, even alone or at the edges', () {
const invalid = DisplayNameError.invalidCharacter;
expect(DisplayNameRule.validate('​'), invalid);
expect(DisplayNameRule.validate('Anna​'), invalid);
expect(DisplayNameRule.validate('​Anna'), invalid);
expect(DisplayNameRule.validate('An​na'), invalid);
expect(DisplayNameRule.validate(' ​ '), invalid);
});
test('does not treat ZWJ/ZWNJ as forbidden', () {
expect(DisplayNameRule.validate('a‍b‌'), isNull);
});
});
}

View file

@ -0,0 +1,38 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
void main() {
group('KTO-01 Kriterium 1: EmailFormat', () {
test('accepts common addresses (trimmed)', () {
expect(EmailFormat.validate('anna@example.com'), isNull);
expect(EmailFormat.validate(' anna.b+x@mail.example.de '), isNull);
});
test('rejects empty input', () {
expect(EmailFormat.validate(''), EmailError.empty);
expect(EmailFormat.validate(' '), EmailError.empty);
});
test('rejects malformed addresses', () {
for (final value in [
'anna',
'anna@',
'@example.com',
'anna@example',
'anna@@example.com',
'an na@example.com',
'anna@example..com',
'anna@.com',
]) {
expect(EmailFormat.validate(value), EmailError.invalid, reason: value);
}
});
test('rejects addresses longer than 254 characters', () {
expect(
EmailFormat.validate('${'a' * 250}@example.com'),
EmailError.invalid,
);
});
});
}

View file

@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/auth/domain/validation/common_passwords.dart';
import 'package:shuffle/features/auth/domain/validation/password_policy.dart';
void main() {
const common = SetCommonPasswordList({'passwort1234', 'hallo12345'});
group('KTO-01 Kriterium 3: PasswordPolicy', () {
test('rejects 9 characters and reports how many are missing', () {
expect(
PasswordPolicy.validate('123456789'),
const PasswordError.tooShort(1),
);
expect(PasswordPolicy.validate(''), const PasswordError.tooShort(10));
});
test('accepts exactly 10 characters', () {
expect(PasswordPolicy.validate('abcdefghij'), isNull);
});
test('counts code points, not UTF-16 units', () {
expect(
PasswordPolicy.validate('😀' * 9),
const PasswordError.tooShort(1),
);
expect(PasswordPolicy.validate('😀' * 10), isNull);
});
test('does not trim for the length check', () {
expect(PasswordPolicy.validate(' a'), isNull);
});
test('rejects common passwords ignoring case', () {
expect(
PasswordPolicy.validate('Passwort1234', common: common),
const PasswordError.common(),
);
expect(
PasswordPolicy.validate('HALLO12345', common: common),
const PasswordError.common(),
);
});
test('compares common passwords after trim', () {
expect(
PasswordPolicy.validate(' hallo12345 ', common: common),
const PasswordError.common(),
);
});
test('too short wins over common', () {
expect(
PasswordPolicy.validate(
'hallo',
common: const SetCommonPasswordList({'hallo'}),
),
const PasswordError.tooShort(5),
);
});
test('checks only the length while the list is not loaded', () {
expect(PasswordPolicy.validate('passwort1234'), isNull);
});
test('accepts an uncommon long password', () {
expect(
PasswordPolicy.validate('korrekt-pferd-batterie', common: common),
isNull,
);
});
});
}

View file

@ -0,0 +1,147 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/routing/app_routes.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
const _user = AuthUser(
id: 'u1',
email: 'anna@example.com',
emailConfirmed: true,
);
const _signedOut = AuthStatus.signedOut();
const _signedIn = AuthStatus.signedIn(_user);
const _recovery = AuthStatus.passwordRecovery(_user);
String? _redirect(
AuthStatus status,
String location, {
List<String> privatePaths = const [],
}) => authRedirectFor(status, Uri.parse(location), privatePaths: privatePaths);
void main() {
group('KTO-01 Kriterium 8: passwordRecovery', () {
test('every route except /reset-password leads to /reset-password', () {
for (final path in [
'/',
'/login',
'/register',
'/forgot-password',
'/auth/confirm?token_hash=x&type=recovery',
'/anything',
]) {
expect(
_redirect(_recovery, path),
AppRoutes.resetPassword,
reason: path,
);
}
});
test('/reset-password stays', () {
expect(_redirect(_recovery, '/reset-password'), isNull);
});
});
group('KTO-01 Kriterium 5: signedIn', () {
test('guest-only pages lead to the start page', () {
for (final path in ['/login', '/register', '/forgot-password']) {
expect(_redirect(_signedIn, path), AppRoutes.home, reason: path);
}
});
test('login honours a safe from parameter', () {
expect(
_redirect(_signedIn, '/login?from=%2Fprivate%3Fa%3D1'),
'/private?a=1',
);
});
test('unsafe from values fall back to the start page', () {
for (final from in [
'https://evil.example',
'//evil.example',
r'/\evil.example',
'private',
'/login',
'/reset-password',
'/auth/confirm',
]) {
expect(
_redirect(_signedIn, '/login?from=${Uri.encodeComponent(from)}'),
AppRoutes.home,
reason: from,
);
}
});
test('public pages, check-email and the link page stay', () {
for (final path in ['/', '/register/check-email', '/auth/confirm?x=1']) {
expect(_redirect(_signedIn, path), isNull, reason: path);
}
});
test('/reset-password is only for passwordRecovery', () {
expect(_redirect(_signedIn, '/reset-password'), AppRoutes.home);
});
});
group('Sicherheit Kriterium 2: signedOut', () {
test('public pages stay reachable', () {
for (final path in [
'/',
'/login',
'/register',
'/register/check-email',
'/forgot-password',
'/auth/confirm?token_hash=x&type=email',
'/does-not-exist',
]) {
expect(_redirect(_signedOut, path), isNull, reason: path);
}
});
test('/reset-password is only for passwordRecovery', () {
expect(_redirect(_signedOut, '/reset-password'), AppRoutes.login);
});
test('private routes lead to /login?from=<path>', () {
final target = _redirect(
_signedOut,
'/collection/42?tab=games',
privatePaths: ['/collection'],
);
final uri = Uri.parse(target!);
expect(uri.path, AppRoutes.login);
expect(uri.queryParameters['from'], '/collection/42?tab=games');
});
test('private prefix matches whole segments only', () {
expect(
_redirect(_signedOut, '/collections', privatePaths: ['/collection']),
isNull,
);
expect(
_redirect(_signedOut, '/collection', privatePaths: ['/collection']),
isNotNull,
);
});
test('without private paths nothing is private (404 stays a 404)', () {
expect(_redirect(_signedOut, '/collection'), isNull);
});
});
group('safeFromPath', () {
test('accepts internal paths with query', () {
expect(safeFromPath('/games/1?x=2'), '/games/1?x=2');
});
test('rejects null, empty and absolute URLs', () {
expect(safeFromPath(null), isNull);
expect(safeFromPath(''), isNull);
expect(safeFromPath('javascript:alert(1)'), isNull);
});
});
}

View file

@ -0,0 +1,248 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/routing/app_router.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
import '../../../helpers/fakes.dart';
import '../../../helpers/pump_app.dart';
import '../../../helpers/router_helpers.dart';
import '../data/auth_test_support.dart';
void main() {
late SupabaseMocks mocks;
late StreamController<AuthState> events;
late Session? session;
late int signedOutCalls;
setUp(() {
mocks = SupabaseMocks();
events = StreamController<AuthState>.broadcast(sync: true);
session = null;
signedOutCalls = 0;
when(() => mocks.auth.currentSession).thenAnswer((_) => session);
when(() => mocks.auth.onAuthStateChange).thenAnswer((_) => events.stream);
});
tearDown(() => events.close());
void emit(AuthChangeEvent event, {Session? withSession}) {
session = withSession;
events.add(AuthState(event, withSession));
}
Future<ProviderContainerAndRouter> start(WidgetTester tester) async {
final container = await pumpShuffleApp(
tester,
overrides: testOverrides(
supabase: mocks.client,
overrides: [
...authOverrides(),
privateRoutePathsProvider.overrideWithValue(const ['/private']),
signedOutHandlersProvider.overrideWithValue([
(ref) => signedOutCalls++,
]),
],
),
);
return (container: container, router: container.read(appRouterProvider));
}
group('KTO-01 Kriterium 5: authStatusProvider', () {
testWidgets('maps events and starts with the stored session', (
tester,
) async {
session = testSession();
final (:container, router: _) = await start(tester);
expect(container.read(authStatusProvider).value, isA<SignedIn>());
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(container.read(authStatusProvider).value, isA<SignedOut>());
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(container.read(authStatusProvider).value, isA<SignedIn>());
});
});
group('Sicherheit Kriterium 2: router redirects', () {
testWidgets('private route while signed out -> /login?from=', (
tester,
) async {
final (container: _, :router) = await start(tester);
router.go('/private?tab=1');
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
expect(currentUri(router).queryParameters['from'], '/private?tab=1');
expect(find.byKey(const Key('auth_placeholder_login')), findsOneWidget);
});
testWidgets('signing in on /login returns to from', (tester) async {
final (container: _, :router) = await start(tester);
router.go('/private');
await tester.pumpAndSettle();
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/private');
});
testWidgets('signed in: login and register lead to the start page', (
tester,
) async {
session = testSession();
final (container: _, :router) = await start(tester);
router.go('/login');
await tester.pumpAndSettle();
expect(currentPath(router), '/');
router.go('/register');
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets('placeholder routes of spec 5.3 exist', (tester) async {
final (container: _, :router) = await start(tester);
for (final (path, key) in [
('/login', 'login'),
('/register', 'register'),
('/register/check-email', 'check_email'),
('/forgot-password', 'forgot_password'),
('/auth/confirm?token_hash=x&type=email', 'auth_confirm'),
]) {
router.go(path);
await tester.pumpAndSettle();
expect(
find.byKey(Key('auth_placeholder_$key')),
findsOneWidget,
reason: path,
);
}
});
});
group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () {
testWidgets('recovery forces /reset-password until the password changed', (
tester,
) async {
final (container: _, :router) = await start(tester);
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
expect(
find.byKey(const Key('auth_placeholder_reset_password')),
findsOneWidget,
);
router.go('/');
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
emit(AuthChangeEvent.tokenRefreshed, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/reset-password');
emit(AuthChangeEvent.userUpdated, withSession: testSession());
await tester.pumpAndSettle();
expect(currentPath(router), '/');
});
testWidgets('aborting the recovery (signOut) leaves /reset-password', (
tester,
) async {
final (container: _, :router) = await start(tester);
emit(AuthChangeEvent.passwordRecovery, withSession: testSession());
await tester.pumpAndSettle();
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
});
testWidgets('onSignedOut runs on signed-in -> signed-out only', (
tester,
) async {
await start(tester);
expect(signedOutCalls, 0, reason: 'initial signed-out state');
emit(AuthChangeEvent.signedIn, withSession: testSession());
await tester.pumpAndSettle();
expect(signedOutCalls, 0);
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(signedOutCalls, 1);
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(signedOutCalls, 1, reason: 'no transition');
});
testWidgets('a failed refresh on a private route ends on /login', (
tester,
) async {
session = testSession();
final (container: _, :router) = await start(tester);
router.go('/private');
await tester.pumpAndSettle();
expect(currentPath(router), '/private');
emit(AuthChangeEvent.signedOut);
await tester.pumpAndSettle();
expect(currentPath(router), '/login');
expect(signedOutCalls, 1);
});
});
group('KTO-01 Kriterium 1: registrationInputFactory', () {
testWidgets('fills terms_version from AppConfig and the app language', (
tester,
) async {
final (:container, router: _) = await start(tester);
final input = container.read(registrationInputFactoryProvider)(
email: 'a@b.de',
password: 'pw',
displayName: 'Anna',
ageConfirmed: true,
termsAccepted: true,
);
expect(
input,
const RegistrationInput(
email: 'a@b.de',
password: 'pw',
displayName: 'Anna',
ageConfirmed: true,
termsAccepted: true,
termsVersion: 'test-1',
locale: 'en',
),
);
});
});
}
typedef ProviderContainerAndRouter = ({
ProviderContainer container,
GoRouter router,
});

View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Builds app/assets/security/common-passwords.txt from SecLists (MIT).
Usage:
build-common-passwords.py <xato-1000000.txt> <german-top-100000.txt> <out.txt>
Inputs (SecLists commit 68bcd12457978649d41ae4f56e3b5d2db2b72560,
Passwords/Common-Credentials/):
xato-net-10-million-passwords-1000000.txt
Language-Specific/German_common-password-list-top-100000.txt
Rules: lowercase, trimmed, at least 10 code points (shorter ones are caught by
the length rule), printable, unique. First GERMAN_COUNT entries of the German
list (frequency order), then XATO_COUNT entries of the xato list.
"""
import sys
GERMAN_COUNT = 4000
XATO_COUNT = 6000
MIN_LENGTH = 10
def take(path, limit, seen):
out = []
with open(path, encoding="utf-8", errors="ignore") as handle:
for line in handle:
entry = line.strip().lower()
if (
len(entry) >= MIN_LENGTH
and entry.isprintable()
and not entry.startswith("#")
and entry not in seen
):
seen.add(entry)
out.append(entry)
if len(out) >= limit:
break
return out
def main():
xato, german, target = sys.argv[1:4]
seen = set()
entries = take(german, GERMAN_COUNT, seen) + take(xato, XATO_COUNT, seen)
with open(target, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(entries) + "\n")
print(len(entries))
if __name__ == "__main__":
main()