fix(auth): address review findings for registration
All checks were successful
CI / flutter (pull_request) Successful in 1m16s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 32s

- expose legal links to screen readers on web
- allow only http(s) legal URLs, prevent double submit
- detect transport errors without runtimeType, keep extra in memory
- ship third-party license text, document decisions

Refs #1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-03 00:47:42 +01:00
parent e632da2344
commit 239d0e1ac2
39 changed files with 489 additions and 92 deletions

View file

@ -23,13 +23,18 @@ Commit `68bcd12457978649d41ae4f56e3b5d2db2b72560`, Verzeichnis
| `Language-Specific/German_common-password-list-top-100000.txt` | die ersten 4.000 Einträge mit mindestens 10 Zeichen | | `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 | | `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 **Lizenzfrage entschieden (Chris, 2026-11-03):** Die MIT-Lizenz gilt für das
einzelnen Listen stammen aus Datenlecks bzw. Sammlungen Dritter (die xato-Liste SecLists-Repository. Die einzelnen Listen stammen aus Datenlecks bzw.
von Mark Burnett); eine eigene Lizenz je Liste nennt SecLists nicht. Die Spec Sammlungen Dritter (die xato-Liste von Mark Burnett); eine eigene Lizenz je
(§ 5.6) nennt SecLists (MIT) als Kandidaten. Die abschließende Freigabe liegt Liste nennt SecLists nicht. Chris hat entschieden, die SecLists-Listen mit
bei Chris. Die Liste enthält ausschließlich Passwörter, keine Konten oder dokumentierter Herkunft (diese Datei) und MIT-Lizenztext (`app/THIRD_PARTY.md`)
zu nutzen. Die Liste enthält ausschließlich Passwörter, keine Konten oder
E-Mail-Adressen. E-Mail-Adressen.
Der Lizenztext wird zur Laufzeit über `LicenseRegistry` ausgeliefert
(`lib/core/licenses/third_party_licenses.dart`, Asset `THIRD_PARTY.md`) und
erscheint damit in `showLicensePage`.
## Neu erzeugen ## Neu erzeugen
```bash ```bash

View file

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Asset with the license texts of bundled third-party material.
const thirdPartyAsset = 'THIRD_PARTY.md';
/// Package name under which the entry appears in `showLicensePage`.
const thirdPartyLicensePackage = 'SecLists (common passwords list)';
/// Registers the license text of the bundled password list (SecLists, MIT) in
/// the [LicenseRegistry], so that it shows up in `showLicensePage`. The text
/// is read lazily from [thirdPartyAsset] when the license page opens.
void registerThirdPartyLicenses({AssetBundle? bundle}) {
LicenseRegistry.addLicense(() async* {
final text = await (bundle ?? rootBundle).loadString(thirdPartyAsset);
yield LicenseEntryWithLineBreaks([thirdPartyLicensePackage], text);
});
}

View file

@ -0,0 +1,6 @@
library;
/// Detects `dart:io` network errors without importing `dart:io` on the web
/// (conditional import). Type checks with `is` keep working in minified
/// dart2js builds, unlike `runtimeType.toString()`.
export 'io_error_stub.dart' if (dart.library.io) 'io_error_io.dart';

View file

@ -0,0 +1,7 @@
import 'dart:io';
/// Network errors of `dart:io` (no connection, DNS, TLS handshake).
bool isIoNetworkError(Object error) =>
error is SocketException ||
error is HandshakeException ||
error is TlsException;

View file

@ -0,0 +1,2 @@
/// Web and other platforms without `dart:io`: no `dart:io` network errors.
bool isIoNetworkError(Object error) => false;

View file

@ -9,13 +9,30 @@ typedef UrlOpener = Future<bool> Function(Uri uri);
final _log = AppLogger('UrlOpener'); final _log = AppLogger('UrlOpener');
/// Only `http`/`https` URLs with a host may leave the app (no `javascript:`,
/// `file:`, `intent:` and the like). The check lives here so that no caller
/// can forget it.
bool isOpenableExternalUrl(Uri uri) =>
(uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
/// Wraps [launch] with the scheme check of [isOpenableExternalUrl] and error
/// handling. Public for tests.
UrlOpener guardedUrlOpener(Future<bool> Function(Uri uri) launch) =>
(uri) async {
if (!isOpenableExternalUrl(uri)) {
_log.warning('refused to open url with scheme "${uri.scheme}"');
return false;
}
try {
return await launch(uri);
} on Object catch (error) {
_log.warning('launchUrl failed: ${error.runtimeType}');
return false;
}
};
/// The only place that touches `url_launcher`. Tests override this provider. /// The only place that touches `url_launcher`. Tests override this provider.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
UrlOpener urlOpener(Ref ref) => (uri) async { UrlOpener urlOpener(Ref ref) => guardedUrlOpener(
try { (uri) => launchUrl(uri, mode: LaunchMode.externalApplication),
return await launchUrl(uri, mode: LaunchMode.externalApplication); );
} on Object catch (error) {
_log.warning('launchUrl failed: ${error.runtimeType}');
return false;
}
};

View file

@ -52,4 +52,4 @@ final class UrlOpenerProvider
} }
} }
String _$urlOpenerHash() => r'f5f1e650e073776595fac2981548a91f815c66ca'; String _$urlOpenerHash() => r'e86eb081c587907d3c162ed619b42abbbfb195a5';

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/app_routes.dart';
import 'package:shuffle/core/routing/not_found_screen.dart'; import 'package:shuffle/core/routing/not_found_screen.dart';
import 'package:shuffle/core/routing/redirect_source.dart'; import 'package:shuffle/core/routing/redirect_source.dart';
import 'package:shuffle/core/routing/volatile_extra_codec.dart';
import 'package:shuffle/features/auth/presentation/auth_routes.dart'; import 'package:shuffle/features/auth/presentation/auth_routes.dart';
import 'package:shuffle/features/home/presentation/home_screen.dart'; import 'package:shuffle/features/home/presentation/home_screen.dart';
@ -14,6 +15,7 @@ GoRouter appRouter(Ref ref) {
final router = GoRouter( final router = GoRouter(
initialLocation: AppRoutes.home, initialLocation: AppRoutes.home,
extraCodec: const VolatileExtraCodec(),
refreshListenable: source.changes, refreshListenable: source.changes,
redirect: (context, state) => source.redirect(state), redirect: (context, state) => source.redirect(state),
errorBuilder: (context, state) => const NotFoundScreen(), errorBuilder: (context, state) => const NotFoundScreen(),

View file

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

View file

@ -0,0 +1,26 @@
import 'dart:convert';
/// `extraCodec` for go_router that does not persist `extra`.
///
/// go_router 18 JSON-encodes `extra` into the route information, and on the
/// web into the browser history state (`RouteMatchListCodec`,
/// `match.dart`). Data that must not outlive the page (here: the e-mail
/// address on "check your inbox") would stay in the history entry. With this
/// codec `extra` works in memory during navigation, but is dropped when the
/// state is serialized (reload, restoring from history).
class VolatileExtraCodec extends Codec<Object?, Object?> {
const VolatileExtraCodec();
@override
Converter<Object?, Object?> get encoder => const _DropConverter();
@override
Converter<Object?, Object?> get decoder => const _DropConverter();
}
class _DropConverter extends Converter<Object?, Object?> {
const _DropConverter();
@override
Object? convert(Object? input) => null;
}

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:shuffle/core/theme/app_spacing.dart'; import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/core/widgets/app_field_error.dart'; import 'package:shuffle/core/widgets/app_field_error.dart';
@ -8,8 +7,8 @@ import 'package:shuffle/core/widgets/app_field_error.dart';
class AppLabelSegment { class AppLabelSegment {
const AppLabelSegment.text(this.text) : onTap = null; const AppLabelSegment.text(this.text) : onTap = null;
/// A link. It is focusable via keyboard and exposed to screen readers as a /// A link. It is focusable via keyboard and exposed to screen readers as its
/// custom action named [text]. /// own semantics node (`link: true`) with [text] as label.
const AppLabelSegment.link(this.text, this.onTap); const AppLabelSegment.link(this.text, this.onTap);
final String text; final String text;
@ -60,11 +59,8 @@ class AppCheckboxField extends StatelessWidget {
decorationColor: theme.colorScheme.primary, decorationColor: theme.colorScheme.primary,
); );
final hasLinks = segments.any((s) => s.isLink);
final plainLabel = segments.map((s) => s.text).join(); final plainLabel = segments.map((s) => s.text).join();
final linkActions = <CustomSemanticsAction, VoidCallback>{
for (final s in segments.where((s) => s.isLink))
CustomSemanticsAction(label: s.text): s.onTap!,
};
final label = Text.rich( final label = Text.rich(
TextSpan( TextSpan(
@ -82,7 +78,9 @@ class AppCheckboxField extends StatelessWidget {
), ),
) )
else else
TextSpan(text: s.text), // With links the text stays in the semantics tree (the links are its
// children) but is read via the checkbox label, not twice.
TextSpan(text: s.text, semanticsLabel: hasLinks ? '' : null),
], ],
), ),
); );
@ -94,40 +92,40 @@ class AppCheckboxField extends StatelessWidget {
Semantics( Semantics(
container: true, container: true,
checked: value, checked: value,
enabled: interactive,
label: plainLabel, label: plainLabel,
enabled: interactive,
onTap: interactive ? () => onChanged!(!value) : null, onTap: interactive ? () => onChanged!(!value) : null,
customSemanticsActions: linkActions, child: InkWell(
child: ExcludeSemantics( canRequestFocus: false,
child: InkWell( excludeFromSemantics: true,
canRequestFocus: false, borderRadius: AppRadius.smAll,
borderRadius: AppRadius.smAll, onTap: interactive ? () => onChanged!(!value) : null,
onTap: interactive ? () => onChanged!(!value) : null, child: ConstrainedBox(
child: ConstrainedBox( constraints: const BoxConstraints(
constraints: const BoxConstraints( minHeight: AppSpacing.minTouchTarget,
minHeight: AppSpacing.minTouchTarget, ),
), child: Row(
child: Row( crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, children: [
children: [ ExcludeSemantics(
Checkbox( child: Checkbox(
value: value, value: value,
isError: hasError, isError: hasError,
onChanged: interactive onChanged: interactive
? (v) => onChanged!(v ?? false) ? (v) => onChanged!(v ?? false)
: null, : null,
), ),
const SizedBox(width: AppSpacing.xs), ),
Expanded( const SizedBox(width: AppSpacing.xs),
child: Padding( Expanded(
padding: const EdgeInsets.symmetric( child: Padding(
vertical: AppSpacing.sm, padding: const EdgeInsets.symmetric(
), vertical: AppSpacing.sm,
child: label,
), ),
child: label,
), ),
], ),
), ],
), ),
), ),
), ),
@ -158,11 +156,21 @@ class _InlineLink extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( // Own semantics node (`link: true`), so that screen readers reach the
// link on the web, too (custom actions are not mapped to ARIA there).
return Semantics(
container: true,
link: true,
label: text,
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm / 2), excludeSemantics: true,
focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), child: InkWell(
child: Text(text, style: style), onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm / 2),
focusColor: Theme.of(context).colorScheme.primary
.withValues(alpha: 0.2),
child: Text(text, style: style),
),
); );
} }
} }

View file

@ -5,10 +5,16 @@ import 'package:shuffle/core/theme/app_spacing.dart';
/// ///
/// Information is never conveyed by color alone: icon + text. The text is a /// Information is never conveyed by color alone: icon + text. The text is a
/// polite live region, so screen readers announce it when it appears. /// polite live region, so screen readers announce it when it appears.
///
/// With [live] set to `false` the message stays visible and readable but is
/// not announced on its own. Use it for messages that change on every
/// keystroke (e.g. "n characters missing") and switch to `true` when the
/// field is left.
class AppFieldError extends StatelessWidget { class AppFieldError extends StatelessWidget {
const AppFieldError(this.text, {super.key}); const AppFieldError(this.text, {super.key, this.live = true});
final String text; final String text;
final bool live;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -16,7 +22,7 @@ class AppFieldError extends StatelessWidget {
final color = theme.colorScheme.error; final color = theme.colorScheme.error;
return Semantics( return Semantics(
container: true, container: true,
liveRegion: true, liveRegion: live,
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View file

@ -17,6 +17,7 @@ class AppPasswordField extends StatefulWidget {
this.focusNode, this.focusNode,
this.ruleHint, this.ruleHint,
this.errorText, this.errorText,
this.liveError = true,
this.isNewPassword = false, this.isNewPassword = false,
this.autofillHints, this.autofillHints,
this.textInputAction, this.textInputAction,
@ -35,6 +36,9 @@ class AppPasswordField extends StatefulWidget {
/// Rule hint (e.g. "at least 10 characters"), shown while there is no error. /// Rule hint (e.g. "at least 10 characters"), shown while there is no error.
final String? ruleHint; final String? ruleHint;
final String? errorText; final String? errorText;
/// See [AppTextField.liveError].
final bool liveError;
final bool isNewPassword; final bool isNewPassword;
/// Overrides the default hints (`password` / `newPassword`). /// Overrides the default hints (`password` / `newPassword`).
@ -60,6 +64,7 @@ class _AppPasswordFieldState extends State<AppPasswordField> {
focusNode: widget.focusNode, focusNode: widget.focusNode,
helperText: widget.ruleHint, helperText: widget.ruleHint,
errorText: widget.errorText, errorText: widget.errorText,
liveError: widget.liveError,
obscureText: _obscured, obscureText: _obscured,
autocorrect: false, autocorrect: false,
enableSuggestions: false, enableSuggestions: false,

View file

@ -17,6 +17,7 @@ class AppTextField extends StatelessWidget {
this.focusNode, this.focusNode,
this.helperText, this.helperText,
this.errorText, this.errorText,
this.liveError = true,
this.keyboardType, this.keyboardType,
this.textInputAction, this.textInputAction,
this.autofillHints, this.autofillHints,
@ -41,6 +42,10 @@ class AppTextField extends StatelessWidget {
final FocusNode? focusNode; final FocusNode? focusNode;
final String? helperText; final String? helperText;
final String? errorText; final String? errorText;
/// Whether [errorText] is announced as a live region. Set to `false` while
/// the user is typing and the message changes with every keystroke.
final bool liveError;
final TextInputType? keyboardType; final TextInputType? keyboardType;
final TextInputAction? textInputAction; final TextInputAction? textInputAction;
final Iterable<String>? autofillHints; final Iterable<String>? autofillHints;
@ -122,7 +127,9 @@ class AppTextField extends StatelessWidget {
decoration: InputDecoration( decoration: InputDecoration(
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
counterText: showCounter ? counterText : null, counterText: showCounter ? counterText : null,
error: hasError ? AppFieldError(errorText!) : null, error: hasError
? AppFieldError(errorText!, live: liveError)
: null,
helper: !hasError && helper != null && helper.isNotEmpty helper: !hasError && helper != null && helper.isNotEmpty
? Text( ? Text(
helper, helper,

View file

@ -1,5 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:shuffle/core/platform/io_error.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@ -108,13 +110,9 @@ RegistrationRejectionReason registrationRejectionReasonFrom(String message) {
return RegistrationRejectionReason.unknown; return RegistrationRejectionReason.unknown;
} }
/// Rohe Transportfehler (`SocketException`, `ClientException`, …). `dart:io` /// Rohe Transportfehler. `gotrue` verpackt Transportfehler normalerweise
/// und `package:http` werden bewusst nicht importiert (Web-Build, Lint); /// schon selbst; das hier ist die Rückfallebene. Erkennung per `is`
/// `gotrue` verpackt Transportfehler normalerweise schon selbst. /// (`ClientException` aus `package:http`, `dart:io`-Fehler über bedingten
bool _isTransportError(Object error) => switch (error.runtimeType.toString()) { /// Import), nicht über `runtimeType`: Das bricht in minifiziertem dart2js.
'SocketException' || bool _isTransportError(Object error) =>
'ClientException' || error is http.ClientException || isIoNetworkError(error);
'HandshakeException' ||
'TlsException' => true,
_ => false,
};

View file

@ -16,8 +16,10 @@ final List<RouteBase> authRoutes = [
GoRoute( GoRoute(
path: 'check-email', path: 'check-email',
builder: (context, state) { builder: (context, state) {
// The address travels as `extra`, never in the URL. After a web // The address travels as `extra`, never in the URL. go_router would
// reload it is gone; the screen then asks for it. // persist `extra` in the browser history; the router's
// `VolatileExtraCodec` prevents that. After a web reload it is gone;
// the screen then asks for it.
final extra = state.extra; final extra = state.extra;
return CheckEmailScreen(email: extra is String ? extra : null); return CheckEmailScreen(email: extra is String ? extra : null);
}, },

View file

@ -73,8 +73,13 @@ String? safeFromPath(String? from) {
if (uri == null || uri.hasScheme || uri.hasAuthority) { if (uri == null || uri.hasScheme || uri.hasAuthority) {
return null; return null;
} }
if (_neverFromPaths.contains(uri.path) || // `/login/` is the same page as `/login` (go_router ignores the trailing
uri.path.startsWith(AppRoutes.authConfirm)) { // slash), so it is not allowed either.
final path = uri.path.length > 1
? uri.path.replaceFirst(RegExp(r'/+$'), '')
: uri.path;
if (_neverFromPaths.contains(path) ||
path.startsWith(AppRoutes.authConfirm)) {
return null; return null;
} }
return from; return from;

View file

@ -85,6 +85,9 @@ class RegisterController extends _$RegisterController {
if (state.isSubmitting) { if (state.isSubmitting) {
return false; return false;
} }
// Set before the first await, otherwise a second trigger (button and
// Enter key) passes the guard while the list is still loading.
state = state.copyWith(status: RegisterStatus.submitting);
await loadCommonPasswords(); await loadCommonPasswords();
if (!ref.mounted) { if (!ref.mounted) {
return false; return false;
@ -100,13 +103,13 @@ class RegisterController extends _$RegisterController {
final invalid = state.firstInvalidField; final invalid = state.firstInvalidField;
if (invalid != null) { if (invalid != null) {
state = state.copyWith( state = state.copyWith(
status: RegisterStatus.editing,
focusField: invalid, focusField: invalid,
focusRequest: state.focusRequest + 1, focusRequest: state.focusRequest + 1,
); );
return false; return false;
} }
state = state.copyWith(status: RegisterStatus.submitting);
final input = ref.read(registrationInputFactoryProvider)( final input = ref.read(registrationInputFactoryProvider)(
email: EmailFormat.normalize(state.email), email: EmailFormat.normalize(state.email),
password: state.password, password: state.password,
@ -155,7 +158,7 @@ class RegisterController extends _$RegisterController {
LegalDocument.terms => config.legalTermsUrl, LegalDocument.terms => config.legalTermsUrl,
}; };
final uri = Uri.tryParse(url); final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) { if (uri == null || !isOpenableExternalUrl(uri)) {
_log.warning('invalid legal url for ${document.name}'); _log.warning('invalid legal url for ${document.name}');
return false; return false;
} }

View file

@ -45,7 +45,7 @@ final class RegisterControllerProvider
} }
String _$registerControllerHash() => String _$registerControllerHash() =>
r'25930050b4b5fbaff535c3a09abe4ca15c860759'; r'78a2a99ae36292286f26aff1f9910504bc464636';
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10). /// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).

View file

@ -40,6 +40,9 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _passwordFocus = FocusNode(); final _passwordFocus = FocusNode();
final _displayNameFocus = FocusNode(); final _displayNameFocus = FocusNode();
/// The user is typing in the password field (see `liveError` below).
bool _passwordTyping = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -54,6 +57,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
); );
} }
_onFocusChange(_passwordFocus, RegisterField.password); _onFocusChange(_passwordFocus, RegisterField.password);
if (!_passwordFocus.hasFocus && _passwordTyping && mounted) {
// Field left: the length hint may be announced now.
setState(() => _passwordTyping = false);
}
}); });
_displayNameFocus.addListener( _displayNameFocus.addListener(
() => _onFocusChange(_displayNameFocus, RegisterField.displayName), () => _onFocusChange(_displayNameFocus, RegisterField.displayName),
@ -167,7 +174,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
enabled: !busy, enabled: !busy,
ruleHint: l10n.authPasswordRule, ruleHint: l10n.authPasswordRule,
errorText: _passwordError(l10n, state), errorText: _passwordError(l10n, state),
onChanged: controller.passwordChanged, // "n missing" changes with every keystroke: announce it only
// when the field is left. The visible hint stays.
liveError: !_passwordTyping || !_isLengthHintOnly(state),
onChanged: (value) {
if (!_passwordTyping) {
setState(() => _passwordTyping = true);
}
controller.passwordChanged(value);
},
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
AppTextField( AppTextField(
@ -258,6 +273,13 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
return null; return null;
} }
/// The only password message shown is the "n missing" count (no server
/// answer, not "required").
bool _isLengthHintOnly(RegisterState state) =>
state.serverPasswordReasons == null &&
!state.shownPasswordRequired &&
state.shownPasswordError is PasswordTooShort;
String? _passwordError(AppLocalizations l10n, RegisterState state) { String? _passwordError(AppLocalizations l10n, RegisterState state) {
if (state.serverPasswordReasons case final reasons?) { if (state.serverPasswordReasons case final reasons?) {
return weakPasswordMessage(l10n, reasons); return weakPasswordMessage(l10n, reasons);

View file

@ -20,7 +20,7 @@
"authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.", "authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.",
"authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"authActionRetry": "Erneut versuchen", "authActionRetry": "Erneut versuchen",
"authPasswordTooShort": "Mindestens 10 Zeichen – es fehlen noch {missing}.", "authPasswordTooShort": "Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}.",
"authPasswordCommon": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.", "authPasswordCommon": "Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes.",
"authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.", "authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.",
"authPasswordRequired": "Gib ein Passwort ein.", "authPasswordRequired": "Gib ein Passwort ein.",

View file

@ -35,7 +35,7 @@
"authErrorNetwork": "No connection. You need internet access for this step.", "authErrorNetwork": "No connection. You need internet access for this step.",
"authErrorUnexpected": "Something went wrong. Please try again.", "authErrorUnexpected": "Something went wrong. Please try again.",
"authActionRetry": "Try again", "authActionRetry": "Try again",
"authPasswordTooShort": "At least 10 characters – {missing} more needed.", "authPasswordTooShort": "At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.",
"@authPasswordTooShort": { "@authPasswordTooShort": {
"placeholders": { "placeholders": {
"missing": { "missing": {

View file

@ -221,7 +221,7 @@ abstract class AppLocalizations {
/// No description provided for @authPasswordTooShort. /// No description provided for @authPasswordTooShort.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'At least 10 characters – {missing} more needed.'** /// **'At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}.'**
String authPasswordTooShort(int missing); String authPasswordTooShort(int missing);
/// No description provided for @authPasswordCommon. /// No description provided for @authPasswordCommon.

View file

@ -84,7 +84,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String authPasswordTooShort(int missing) { String authPasswordTooShort(int missing) {
return 'Mindestens 10 Zeichen – es fehlen noch $missing.'; String _temp0 = intl.Intl.pluralLogic(
missing,
locale: localeName,
other: 'es fehlen noch $missing Zeichen',
one: 'es fehlt noch 1 Zeichen',
);
return 'Mindestens 10 Zeichen – $_temp0.';
} }
@override @override

View file

@ -81,7 +81,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String authPasswordTooShort(int missing) { String authPasswordTooShort(int missing) {
return 'At least 10 characters – $missing more needed.'; String _temp0 = intl.Intl.pluralLogic(
missing,
locale: localeName,
other: '$missing more characters needed',
one: '1 more character needed',
);
return 'At least 10 characters – $_temp0.';
} }
@override @override

View file

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shuffle/app.dart'; import 'package:shuffle/app.dart';
import 'package:shuffle/core/config/app_config.dart'; import 'package:shuffle/core/config/app_config.dart';
import 'package:shuffle/core/error/global_error_handler.dart'; import 'package:shuffle/core/error/global_error_handler.dart';
import 'package:shuffle/core/licenses/third_party_licenses.dart';
import 'package:shuffle/core/logging/app_logger.dart'; import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/core/routing/url_strategy.dart'; import 'package:shuffle/core/routing/url_strategy.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
@ -11,6 +12,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
AppLogger.configure(); AppLogger.configure();
registerThirdPartyLicenses();
installGlobalErrorHandlers(); installGlobalErrorHandlers();
configurePathUrlStrategy(); configurePathUrlStrategy();

View file

@ -334,7 +334,7 @@ packages:
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View file

@ -23,6 +23,7 @@ dependencies:
json_annotation: 4.12.0 json_annotation: 4.12.0
logging: 1.3.0 logging: 1.3.0
intl: 0.20.3 intl: 0.20.3
http: 1.6.0
url_launcher: 6.3.3 url_launcher: 6.3.3
dev_dependencies: dev_dependencies:
@ -40,3 +41,4 @@ flutter:
generate: true generate: true
assets: assets:
- assets/security/common-passwords.txt - assets/security/common-passwords.txt
- THIRD_PARTY.md

View file

@ -0,0 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/licenses/third_party_licenses.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('the SecLists MIT license shows up in the LicenseRegistry', () async {
registerThirdPartyLicenses();
final entries = await LicenseRegistry.licenses.toList();
final entry = entries.firstWhere(
(e) => e.packages.contains(thirdPartyLicensePackage),
);
final text = entry.paragraphs.map((p) => p.text).join('\n');
expect(text, contains('MIT License'));
expect(text, contains('Copyright (c) 2018 Daniel Miessler'));
expect(text, contains('SecLists'));
});
test('the license asset is declared in the bundle', () async {
final text = await rootBundle.loadString(thirdPartyAsset);
expect(text, contains('Permission is hereby granted'));
});
}

View file

@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/platform/url_opener.dart';
void main() {
group('guardedUrlOpener', () {
test('opens http and https URLs with a host', () async {
final launched = <Uri>[];
final open = guardedUrlOpener((uri) async {
launched.add(uri);
return true;
});
expect(await open(Uri.parse('https://shuffle.example/privacy')), isTrue);
expect(await open(Uri.parse('http://shuffle.example/terms')), isTrue);
expect(launched, hasLength(2));
});
for (final url in [
'javascript:alert(1)',
'file:///etc/passwd',
'intent://scan/#Intent;scheme=zxing;end',
'mailto:a@b.de',
'https:///no-host',
'/relative/path',
'data:text/html,hi',
]) {
test('refuses $url', () async {
var launches = 0;
final open = guardedUrlOpener((uri) async {
launches++;
return true;
});
expect(await open(Uri.parse(url)), isFalse);
expect(launches, 0);
});
}
test('returns false when launching throws', () async {
final open = guardedUrlOpener((uri) async => throw StateError('x'));
expect(await open(Uri.parse('https://shuffle.example')), isFalse);
});
});
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/routing/volatile_extra_codec.dart';
void main() {
test('extra is not serialized (nothing lands in the browser history)', () {
const codec = VolatileExtraCodec();
expect(codec.encode('anna@example.com'), isNull);
expect(codec.decode('anna@example.com'), isNull);
});
testWidgets('go_router history state does not contain the extra', (
tester,
) async {
Object? seen;
final router = GoRouter(
extraCodec: const VolatileExtraCodec(),
initialLocation: '/a',
routes: [
GoRoute(path: '/a', builder: (_, _) => const SizedBox()),
GoRoute(
path: '/b',
builder: (_, state) {
seen = state.extra;
return const SizedBox();
},
),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(MaterialApp.router(routerConfig: router));
router.go('/b', extra: 'anna@example.com');
await tester.pumpAndSettle();
expect(seen, 'anna@example.com', reason: 'extra works in memory');
final info = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
);
expect(info?.state.toString(), isNot(contains('anna@example.com')));
});
}

View file

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_checkbox_field.dart'; import 'package:shuffle/core/widgets/app_checkbox_field.dart';
@ -79,11 +78,13 @@ void main() {
await tester.tap(find.text('terms')); await tester.tap(find.text('terms'));
expect(opened, 1); expect(opened, 1);
expect(toggled, 0); expect(toggled, 0);
await tester.tap(find.textContaining('I accept the')); await tester.tapAt(
tester.getTopLeft(find.byType(AppCheckboxField)) + const Offset(30, 24),
);
expect(toggled, 1); expect(toggled, 1);
}); });
testWidgets('exposes checked state, full label and link actions', ( testWidgets('exposes checked state and full label on the checkbox node', (
tester, tester,
) async { ) async {
final handle = tester.ensureSemantics(); final handle = tester.ensureSemantics();
@ -107,12 +108,43 @@ void main() {
hasEnabledState: true, hasEnabledState: true,
isEnabled: true, isEnabled: true,
hasTapAction: true, hasTapAction: true,
customActions: [const CustomSemanticsAction(label: 'terms')],
), ),
); );
handle.dispose(); handle.dispose();
}); });
testWidgets('every link is its own semantics node with isLink (web)', (
tester,
) async {
final handle = tester.ensureSemantics();
var privacy = 0;
var terms = 0;
await pumpWidgetUnderTest(
tester,
AppCheckboxField.rich(
value: false,
onChanged: (_) {},
segments: [
const AppLabelSegment.text('I accept the '),
AppLabelSegment.link('terms', () => terms++),
const AppLabelSegment.text(' and the '),
AppLabelSegment.link('privacy policy', () => privacy++),
],
),
);
for (final name in ['terms', 'privacy policy']) {
expect(
tester.getSemantics(find.bySemanticsLabel(name)),
matchesSemantics(label: name, isLink: true, hasTapAction: true),
);
}
tester.semantics.tap(find.semantics.byLabel('terms'));
tester.semantics.tap(find.semantics.byLabel('privacy policy'));
expect(terms, 1);
expect(privacy, 1);
handle.dispose();
});
testWidgets('error shows icon and text as live region, error border', ( testWidgets('error shows icon and text as live region, error border', (
tester, tester,
) async { ) async {

View file

@ -77,6 +77,26 @@ void main() {
handle.dispose(); handle.dispose();
}); });
testWidgets('liveError false keeps the error visible but silent', (
tester,
) async {
final handle = tester.ensureSemantics();
await pumpWidgetUnderTest(
tester,
const AppTextField(
label: 'Email',
errorText: 'Broken',
liveError: false,
),
);
expect(find.text('Broken'), findsOneWidget);
expect(
tester.getSemantics(find.text('Broken')),
matchesSemantics(label: 'Broken', isLiveRegion: false),
);
handle.dispose();
});
testWidgets('reports changes and keeps input', (tester) async { testWidgets('reports changes and keeps input', (tester) async {
final controller = TextEditingController(); final controller = TextEditingController();
addTearDown(controller.dispose); addTearDown(controller.dispose);

View file

@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:shuffle/features/auth/data/auth_failure_mapper.dart'; import 'package:shuffle/features/auth/data/auth_failure_mapper.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser;
@ -10,10 +12,6 @@ AuthApiException _api(String code, {String status = '400'}) =>
const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}'; const _triggerBody = '{"code":"P0001","message":"display_name_invalid"}';
class SocketException implements Exception {}
class ClientException implements Exception {}
void main() { void main() {
group('Spec #1 § 4.4 error mapping', () { group('Spec #1 § 4.4 error mapping', () {
test('KTO-01 Kriterium 7: invalid_credentials', () { test('KTO-01 Kriterium 7: invalid_credentials', () {
@ -234,11 +232,15 @@ void main() {
const AuthFailure.network(), const AuthFailure.network(),
); );
expect( expect(
mapAuthError(SocketException(), AuthOperation.signIn), mapAuthError(const SocketException('x'), AuthOperation.signIn),
const AuthFailure.network(), const AuthFailure.network(),
); );
expect( expect(
mapAuthError(ClientException(), AuthOperation.signIn), mapAuthError(http.ClientException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(const HandshakeException('x'), AuthOperation.signIn),
const AuthFailure.network(), const AuthFailure.network(),
); );
}); });

View file

@ -134,6 +134,13 @@ void main() {
}); });
group('safeFromPath', () { group('safeFromPath', () {
test('rejects auth pages with a trailing slash', () {
expect(safeFromPath('/login/'), isNull);
expect(safeFromPath('/login//'), isNull);
expect(safeFromPath('/login/?x=1'), isNull);
expect(safeFromPath('/reset-password/'), isNull);
});
test('accepts internal paths with query', () { test('accepts internal paths with query', () {
expect(safeFromPath('/games/1?x=2'), '/games/1?x=2'); expect(safeFromPath('/games/1?x=2'), '/games/1?x=2');
}); });

View file

@ -120,6 +120,31 @@ void main() {
}); });
}); });
test(
'two triggers while the password list is still loading send only once',
() async {
final listLoaded = Completer<CommonPasswordList>();
final container = makeContainer(commonPasswords: () => listLoaded.future);
fillValid(notifier(container));
final first = notifier(container).submit();
final second = notifier(container).submit();
expect(stateOf(container).isSubmitting, isTrue);
expect(await second, isFalse);
listLoaded.complete(commonTestPasswords);
expect(await first, isTrue);
verify(() => repository.signUp(any())).called(1);
expect(stateOf(container).isSubmitting, isFalse);
},
);
test('an invalid form leaves the submitting state again', () async {
final container = makeContainer();
expect(await notifier(container).submit(), isFalse);
expect(stateOf(container).isSubmitting, isFalse);
});
group('KTO-01 Kriterium 2: minimum age', () { group('KTO-01 Kriterium 2: minimum age', () {
test( test(
'without the age confirmation nothing is sent and the error shows', 'without the age confirmation nothing is sent and the error shows',

View file

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:shuffle/core/platform/url_opener.dart'; import 'package:shuffle/core/platform/url_opener.dart';
import 'package:shuffle/core/result/result.dart'; import 'package:shuffle/core/result/result.dart';
import 'package:shuffle/core/widgets/app_field_error.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/registration_input.dart'; import 'package:shuffle/features/auth/domain/registration_input.dart';
import 'package:shuffle/l10n/app_localizations_de.dart'; import 'package:shuffle/l10n/app_localizations_de.dart';
@ -164,6 +165,33 @@ void main() {
expect(find.textContaining('Mindestens 10 Zeichen –'), findsNothing); expect(find.textContaining('Mindestens 10 Zeichen –'), findsNothing);
}); });
testWidgets(
'the length hint is announced when the field is left, not while typing',
(tester) async {
await open(tester);
bool live() => tester
.widget<AppFieldError>(
find.descendant(
of: find.byKey(const Key('register_password')),
matching: find.byType(AppFieldError),
),
)
.live;
await type(tester, 'register_password', 'abc');
expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget);
expect(live(), isFalse);
await type(tester, 'register_password', 'abcd');
expect(live(), isFalse);
await tapKey(tester, 'register_display_name');
await tester.pump();
expect(find.text(_de.authPasswordTooShort(6)), findsOneWidget);
expect(live(), isTrue);
},
);
testWidgets('names a common password', (tester) async { testWidgets('names a common password', (tester) async {
await open(tester); await open(tester);

View file

@ -136,7 +136,7 @@ Neutrale Texte (Krit. 4, F12) verraten nie, ob eine Adresse registriert ist.
| Schlüssel | de | en | | Schlüssel | de | en |
|---|---|---| |---|---|---|
| `authPasswordTooShort` | Mindestens 10 Zeichen – es fehlen noch {missing}. | At least 10 characters – {missing} more needed. | | `authPasswordTooShort` | Mindestens 10 Zeichen – {missing, plural, =1{es fehlt noch 1 Zeichen} other{es fehlen noch {missing} Zeichen}}. | At least 10 characters – {missing, plural, =1{1 more character needed} other{{missing} more characters needed}}. |
| `authPasswordCommon` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. | | `authPasswordCommon` | Dieses Passwort ist sehr verbreitet und leicht zu erraten. Wähle ein anderes. | This password is very common and easy to guess. Choose a different one. |
| `authPasswordRule` | Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort. | At least 10 characters. Not a very common password. | | `authPasswordRule` | Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort. | At least 10 characters. Not a very common password. |
| `authPasswordRequired` | Gib ein Passwort ein. | Enter a password. | | `authPasswordRequired` | Gib ein Passwort ein. | Enter a password. |
@ -209,6 +209,10 @@ teilt – Entscheidung beim flutter-dev (A2).
- Banner und Fehler werden beim Erscheinen vorgelesen (Live-Region); keine reinen Farb-Signale. - Banner und Fehler werden beim Erscheinen vorgelesen (Live-Region); keine reinen Farb-Signale.
- Passwortfelder: `AppPasswordField` mit `isNewPassword: true` bei Register/Reset, sonst `false`; E-Mail mit - Passwortfelder: `AppPasswordField` mit `isNewPassword: true` bei Register/Reset, sonst `false`; E-Mail mit
`AutofillHints.email` (Login: `AutofillHints.username`/`email`), `TextInputType.emailAddress`. `AutofillHints.email` (Login: `AutofillHints.username`/`email`), `TextInputType.emailAddress`.
- Links in Checkbox-Sätzen (`AppCheckboxField.rich`) sind eigene Semantics-Knoten (`link: true`, Label = Linktext,
Tap), keine Custom Actions (Flutter-Web setzt diese nicht in ARIA um). Der Checkbox-Knoten trägt den ganzen Satz.
- Meldungen, die sich bei jedem Tastendruck ändern („es fehlen noch n“), sind während des Tippens keine Live-Region
(`liveError: false`) und werden beim Verlassen des Feldes angesagt; der sichtbare Text bleibt.
- Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar. - Alle interaktiven Elemente >= 48 dp, Tab-Reihenfolge wie Lesereihenfolge, Fokus sichtbar.
- Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`, - Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`,
`labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2). `labeledTapTargetGuideline`, `textContrastGuideline`, Hell und Dunkel (T2).

View file

@ -82,7 +82,7 @@ Nur additiv: `create or replace function` und neue Objekte, keine Änderung an b
| `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := public.trim_ws(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := public.trim_ws(meta->>'display_name')`. Ist der Wert `null`, leer, länger als 30 Codepoints oder enthält U+200B, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). | | `public.handle_new_user()` | – | Trigger-Funktion | **ersetzt** (`create or replace`). Neue Regeln: (a) Alter und Zustimmung wie bisher. (b) `terms_version := public.trim_ws(meta->>'terms_version')`; ist der Wert `null`, leer oder länger als 50 Zeichen, wirft der Trigger `terms_version_required` (P0001). (c) `display_name := public.trim_ws(meta->>'display_name')`. Ist der Wert `null`, leer, länger als 30 Codepoints oder enthält U+200B, wirft der Trigger `display_name_invalid` (SQLSTATE P0001). **Kein Rückgriff auf das E-Mail-Präfix, kein Kürzen.** (d) Der getrimmte Name und `terms_version` werden gespeichert, `terms_accepted_at` und `age_confirmed_at` = `now()`. (e) `locale` aus `meta->>'locale'`, wenn `de`/`en`, **sonst `'en'`** (F6). |
| `public.sync_profile_locale_to_auth()` | – | Trigger-Funktion, `security definer`, `search_path = ''` | `after update of locale on public.profiles`, wenn sich der Wert ändert: `update auth.users set raw_user_meta_data = coalesce(raw_user_meta_data,'{}') \|\| jsonb_build_object('locale', new.locale) where id = new.id`. Ausführen nur für den Trigger: `revoke all … from public, anon, authenticated`. | | `public.sync_profile_locale_to_auth()` | – | Trigger-Funktion, `security definer`, `search_path = ''` | `after update of locale on public.profiles`, wenn sich der Wert ändert: `update auth.users set raw_user_meta_data = coalesce(raw_user_meta_data,'{}') \|\| jsonb_build_object('locale', new.locale) where id = new.id`. Ausführen nur für den Trigger: `revoke all … from public, anon, authenticated`. |
**Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(public.trim_ws(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). Bekannte Abweichung: Dart-`trim()` entfernt auch Unicode-Leerraum (z. B. NBSP), `btrim` nur Leerzeichen. Der Client sendet bereits getrimmte Werte, die Abweichung ist deshalb nur über die rohe API erreichbar und unkritisch. **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen. **Trim-Regel (verbindlich für Client und Server, auch KTO-02):** Leerraum am Anfang und Ende wird entfernt. Danach muss der Name 1 bis 30 **Unicode-Codepoints** lang sein. Der Server prüft mit `char_length(public.trim_ws(…))`, der Client mit `value.trim().runes.length`, **nicht** mit `String.length` (UTF-16). `trim_ws` entfernt denselben Leerraum wie Dart-`trim()` (auch Unicode-Leerraum wie NBSP), Client und Server sind damit gleichwertig; es gibt keine bekannte Abweichung mehr (frühere Fassung: `btrim` nur Leerzeichen, siehe § 10). **Verbotenes Zeichen (Entscheidung Chris):** Ein Anzeigename, der U+200B (Zero Width Space) an irgendeiner Stelle enthält, ist ungültig (Server: `display_name_invalid` bzw. Check `profiles_display_name_no_zwsp`, 23514). U+200B wird nicht getrimmt, sondern abgelehnt. U+200C/U+200D (ZWJ steckt in Emoji-Sequenzen) und U+FEFF (wird getrimmt) sind davon nicht betroffen.
**Keine neuen Tabellen.** `auth.users` und `auth.sessions` verwaltet GoTrue. **Keine neuen Tabellen.** `auth.users` und `auth.sessions` verwaltet GoTrue.
@ -265,7 +265,7 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ
- Erfolg (jede 200-Antwort **und** `emailSendLimited`, F12) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4). - Erfolg (jede 200-Antwort **und** `emailSendLimited`, F12) → `/register/check-email`. Text: „Wir haben dir eine E-Mail geschickt, falls die Adresse noch nicht registriert ist …“. Dieselbe Formulierung gilt auch für bereits registrierte Adressen (Krit. 4).
- `weak_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP). - `weak_password` vom Server wird ans Passwortfeld gemappt (praktisch nur die Mindestlänge, da kein HIBP).
**CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`. **CheckEmailScreen** (Krit. 1, 6, 8): Die E-Mail-Adresse kommt per `extra` und steht nicht in der URL. go_router 18 würde `extra` per JSON in den Browser-Verlaufszustand (History-State) schreiben (`RouteMatchListCodec`, `match.dart`); das verhindert der `VolatileExtraCodec` am Router (`extraCodec`, `extra` bleibt nur im Speicher). Fehlt sie, etwa nach einem Reload im Web, zeigt der Screen ein E-Mail-Feld für das erneute Senden. Weiter: Hinweis, neutraler Zusatz „Die E-Mail kann einige Minuten dauern …“ (immer, F12), Gültigkeit „1 Stunde“ (Wert aus AUTH-CFG-07 als Konstante in `core/config`, mit Verweis auf die Doku), Aktion „E-Mail erneut senden“ (Modus Bestätigung) mit Rückmeldung und Sperre gegen Doppelklick; `emailSendLimited` → dieselbe Rückmeldung wie bei Erfolg (F12, die Adresse kann hier frei eingegeben sein); `rateLimited` (IP-Drosselung) → Drosselungshinweis. Im Modus Reset gibt es keine Resend-Aktion, sondern einen Link zurück zu `/forgot-password`.
**LoginScreen** (01b, Krit. 5, 6, 7) **LoginScreen** (01b, Krit. 5, 6, 7)
@ -291,12 +291,13 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ
- `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy). - `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy).
- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`. - `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`.
- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12).
- Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue<void>` bzw. freezed-Formzustand. Keine Logik in Widgets. - Je Screen ein `@riverpod`-Notifier (`RegisterController` usw.) mit Zustand `AsyncValue<void>` bzw. freezed-Formzustand. Keine Logik in Widgets.
### 5.6 Liste häufiger Passwörter (Krit. 3) ### 5.6 Liste häufiger Passwörter (Krit. 3)
- Asset `app/assets/security/common-passwords.txt`, ca. 10.000 Einträge (Größenordnung 100 KB), in Kleinschreibung, mit deutschen Einträgen (z. B. `passwort1234`, `hallo12345`). Nur Einträge mit mindestens 10 Zeichen sind relevant, kürzere fängt schon die Längenregel. - Asset `app/assets/security/common-passwords.txt`, ca. 10.000 Einträge (Größenordnung 100 KB), in Kleinschreibung, mit deutschen Einträgen (z. B. `passwort1234`, `hallo12345`). Nur Einträge mit mindestens 10 Zeichen sind relevant, kürzere fängt schon die Längenregel.
- Quelle und Lizenz prüft `test-engineer` in Aufgabe A1 und dokumentiert sie in `app/assets/security/README.md`. Kandidat: SecLists (MIT); deutsche Ergänzung mit Quellenangabe. Keine Liste ohne klare Lizenz. - Quelle und Lizenz dokumentiert `app/assets/security/README.md`. **Entschieden (Chris, 2026-11-03):** Die SecLists-Listen (MIT, Herkunft dokumentiert, Commit gepinnt) werden genutzt; die Frage, dass einzelne Listen aus Datenlecks bzw. Sammlungen Dritter stammen, ist damit abgenommen (die Liste enthält nur Passwörter, keine Konten oder E-Mail-Adressen). Der MIT-Text steht in `app/THIRD_PARTY.md`, wird als Asset gebündelt und zur Laufzeit über `LicenseRegistry` in `showLicensePage` ausgeliefert.
- Vergleich ohne Groß-/Kleinschreibung, nach Trim. Die Liste wird lazy beim ersten Öffnen eines Passwortfeldes geladen; im Web fällt sie damit nicht beim Start ins Gewicht. - Vergleich ohne Groß-/Kleinschreibung, nach Trim. Die Liste wird lazy beim ersten Öffnen eines Passwortfeldes geladen; im Web fällt sie damit nicht beim Start ins Gewicht.
### 5.7 Neue Pakete (pub.dev geprüft 2026-11-02) ### 5.7 Neue Pakete (pub.dev geprüft 2026-11-02)
@ -492,3 +493,15 @@ Nach der Freigabe am 2026-11-02 eingearbeitet. Die Texte der Akzeptanzkriterien
12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst. 12. **F12 ergänzt (Chris, 2026-11-02):** Auch „E-Mail erneut senden“ auf dem CheckEmailScreen bestätigt `over_email_send_rate_limit` neutral; im LoginScreen nach `email_not_confirmed` (Krit. 6) bleibt der Drosselungshinweis. `ResendController` mit `ResendOrigin.checkEmail | login` (4.4, 5.4); Tests in 7.1 Krit. 6; R3, R6, F12 angepasst.
13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). Nachgezogen in B2: `secure_password_change` → AUTH-CFG-25 (erledigt). 13. **AUTH-CFG-25** neu: `auth.email.secure_password_change = false`, Entscheidung in KTO-11 (N13); 9.4 verweist darauf. **ID-Abgleich** mit dem B2-Stand (`supabase/config.toml`, `docs/auth-konfiguration.md`): IDs 01–24 stimmen überein. Angeglichen: AUTH-CFG-03 CLI-Schlüssel `auth.email.enabled` (statt `auth.email.enable_signup`). Nachgezogen in B2: `secure_password_change` → AUTH-CFG-25 (erledigt).
14. **Anzeigename: U+200B verboten (Chris, 2026-11-02):** Ein Name mit U+200B an beliebiger Stelle ist ungültig (Trigger `display_name_invalid`, zusätzlicher Check `profiles_display_name_no_zwsp`, Update → 23514; Migration `20261102212208_auth_registration_rules.sql`, pgTAP `05_auth_registration`). U+200C/U+200D/U+FEFF unverändert. Betroffen: E6, 3.1, 3.2 (Check, Trigger, Trim-Regel), RegisterScreen (Client-Regel für 01a: `DisplayNameRule` meldet U+200B als Feldfehler, gleiche Meldung wie „ungültig"). 14. **Anzeigename: U+200B verboten (Chris, 2026-11-02):** Ein Name mit U+200B an beliebiger Stelle ist ungültig (Trigger `display_name_invalid`, zusätzlicher Check `profiles_display_name_no_zwsp`, Update → 23514; Migration `20261102212208_auth_registration_rules.sql`, pgTAP `05_auth_registration`). U+200C/U+200D/U+FEFF unverändert. Betroffen: E6, 3.1, 3.2 (Check, Trigger, Trim-Regel), RegisterScreen (Client-Regel für 01a: `DisplayNameRule` meldet U+200B als Feldfehler, gleiche Meldung wie „ungültig").
### 2026-11-03 – Review-Findings zu 01a (Branch `feature/1-01a-registrierung`)
1. **Passwortliste, Lizenz entschieden (Chris, 2026-11-03):** SecLists (MIT) wird genutzt, Herkunft in `app/assets/security/README.md` dokumentiert (5.6). Der Lizenztext wird zur Laufzeit in `showLicensePage` ausgeliefert (`LicenseRegistry`, Asset `THIRD_PARTY.md`).
2. **Rechtstext-Links im Web:** Die Links der Einwilligungs-Checkbox sind eigene Semantics-Knoten (`link: true`, Label, Tap) statt `CustomSemanticsAction` am Checkbox-Knoten; Flutter-Web setzt zusätzliche Aktionen nicht in ARIA um. Checkbox-Knoten behält Label, Zustand und Tap.
3. **Externe URLs:** `UrlOpener` öffnet nur `http`/`https` mit Host (kein `javascript:`, `file:`, `intent:`).
4. **Doppel-Submit:** `RegisterController.submit` setzt `submitting` vor dem Laden der Passwortliste.
5. **Transportfehler:** Erkennung per `is` (`http.ClientException`, `dart:io`-Fehler über bedingten Import) statt `runtimeType.toString()` (bricht in minifiziertem dart2js). `http` ist als direkte Abhängigkeit deklariert (`1.6.0`, bereits transitiv im Lock; keine neue Fremdabhängigkeit).
6. **Trim-Regel (3.2):** Der Satz „Bekannte Abweichung: Dart-`trim()` … `btrim` nur Leerzeichen“ war falsch. Der Server nutzt `trim_ws` (Abschnitt 3.2), das Verhalten ist mit Dart-`trim()` identisch. Satz korrigiert.
7. **Recovery-Guard (5.5):** Abweichung vom Wortlaut dokumentiert: `tokenRefreshed`/`signedIn` heben `passwordRecovery` nicht auf.
8. **`extra` und Browser-Verlauf (5.4):** Geprüft in go_router 18.1.2 (`match.dart`, `RouteMatchListCodec.encode`): Ohne `extraCodec` wird `extra` per `json.encoder` in den History-State geschrieben und überlebt damit einen Reload (nicht in der URL, aber im Verlaufseintrag). Die Annahme „nach einem Reload ist die Adresse weg“ stimmte nur mit einem Codec. Umsetzung: `VolatileExtraCodec` (`core/routing`) verwirft `extra` beim Serialisieren; im Speicher funktioniert es weiter. Nach einem Reload fragt der Screen die Adresse ab (wie spezifiziert).
9. **Live-Region am Passwortfeld:** Die Meldung „Es fehlen noch n“ wird während des Tippens nicht angesagt (`liveError: false`), bleibt aber sichtbar; beim Verlassen des Feldes wird sie zur Live-Region. Server- und „erforderlich“-Meldungen bleiben sofort live. Plural-Text (ICU) für `authPasswordTooShort`, `safeFromPath` lehnt `/login/` (Schrägstrich am Ende) ab.