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
39 changed files with 489 additions and 92 deletions
Showing only changes of commit 239d0e1ac2 - Show all commits

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 |
| `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
**Lizenzfrage entschieden (Chris, 2026-11-03):** 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. Chris hat entschieden, die SecLists-Listen mit
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.
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
```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');
/// 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.
@Riverpod(keepAlive: true)
UrlOpener urlOpener(Ref ref) => (uri) async {
try {
return await launchUrl(uri, mode: LaunchMode.externalApplication);
} on Object catch (error) {
_log.warning('launchUrl failed: ${error.runtimeType}');
return false;
}
};
UrlOpener urlOpener(Ref ref) => guardedUrlOpener(
(uri) => launchUrl(uri, mode: LaunchMode.externalApplication),
);

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/not_found_screen.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/home/presentation/home_screen.dart';
@ -14,6 +15,7 @@ GoRouter appRouter(Ref ref) {
final router = GoRouter(
initialLocation: AppRoutes.home,
extraCodec: const VolatileExtraCodec(),
refreshListenable: source.changes,
redirect: (context, state) => source.redirect(state),
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/semantics.dart';
import 'package:shuffle/core/theme/app_spacing.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 {
const AppLabelSegment.text(this.text) : onTap = null;
/// A link. It is focusable via keyboard and exposed to screen readers as a
/// custom action named [text].
/// A link. It is focusable via keyboard and exposed to screen readers as its
/// own semantics node (`link: true`) with [text] as label.
const AppLabelSegment.link(this.text, this.onTap);
final String text;
@ -60,11 +59,8 @@ class AppCheckboxField extends StatelessWidget {
decorationColor: theme.colorScheme.primary,
);
final hasLinks = segments.any((s) => s.isLink);
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(
TextSpan(
@ -82,7 +78,9 @@ class AppCheckboxField extends StatelessWidget {
),
)
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(
container: true,
checked: value,
enabled: interactive,
label: plainLabel,
enabled: interactive,
onTap: interactive ? () => onChanged!(!value) : null,
customSemanticsActions: linkActions,
child: ExcludeSemantics(
child: InkWell(
canRequestFocus: false,
borderRadius: AppRadius.smAll,
onTap: interactive ? () => onChanged!(!value) : null,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSpacing.minTouchTarget,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Checkbox(
child: InkWell(
canRequestFocus: false,
excludeFromSemantics: true,
borderRadius: AppRadius.smAll,
onTap: interactive ? () => onChanged!(!value) : null,
child: ConstrainedBox(
constraints: const BoxConstraints(
minHeight: AppSpacing.minTouchTarget,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ExcludeSemantics(
child: Checkbox(
value: value,
isError: hasError,
onChanged: interactive
? (v) => onChanged!(v ?? false)
: null,
),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: label,
),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpacing.sm,
),
child: label,
),
],
),
),
],
),
),
),
@ -158,11 +156,21 @@ class _InlineLink extends StatelessWidget {
@override
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,
borderRadius: BorderRadius.circular(AppRadius.sm / 2),
focusColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
child: Text(text, style: style),
excludeSemantics: true,
child: InkWell(
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
/// 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 {
const AppFieldError(this.text, {super.key});
const AppFieldError(this.text, {super.key, this.live = true});
final String text;
final bool live;
@override
Widget build(BuildContext context) {
@ -16,7 +22,7 @@ class AppFieldError extends StatelessWidget {
final color = theme.colorScheme.error;
return Semantics(
container: true,
liveRegion: true,
liveRegion: live,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View file

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

View file

@ -17,6 +17,7 @@ class AppTextField extends StatelessWidget {
this.focusNode,
this.helperText,
this.errorText,
this.liveError = true,
this.keyboardType,
this.textInputAction,
this.autofillHints,
@ -41,6 +42,10 @@ class AppTextField extends StatelessWidget {
final FocusNode? focusNode;
final String? helperText;
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 TextInputAction? textInputAction;
final Iterable<String>? autofillHints;
@ -122,7 +127,9 @@ class AppTextField extends StatelessWidget {
decoration: InputDecoration(
suffixIcon: suffixIcon,
counterText: showCounter ? counterText : null,
error: hasError ? AppFieldError(errorText!) : null,
error: hasError
? AppFieldError(errorText!, live: liveError)
: null,
helper: !hasError && helper != null && helper.isNotEmpty
? Text(
helper,

View file

@ -1,5 +1,7 @@
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:supabase_flutter/supabase_flutter.dart';
@ -108,13 +110,9 @@ RegistrationRejectionReason registrationRejectionReasonFrom(String message) {
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,
};
/// Rohe Transportfehler. `gotrue` verpackt Transportfehler normalerweise
/// schon selbst; das hier ist die Rückfallebene. Erkennung per `is`
/// (`ClientException` aus `package:http`, `dart:io`-Fehler über bedingten
/// Import), nicht über `runtimeType`: Das bricht in minifiziertem dart2js.
bool _isTransportError(Object error) =>
error is http.ClientException || isIoNetworkError(error);

View file

@ -16,8 +16,10 @@ final List<RouteBase> authRoutes = [
GoRoute(
path: 'check-email',
builder: (context, state) {
// The address travels as `extra`, never in the URL. After a web
// reload it is gone; the screen then asks for it.
// The address travels as `extra`, never in the URL. go_router would
// 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;
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) {
return null;
}
if (_neverFromPaths.contains(uri.path) ||
uri.path.startsWith(AppRoutes.authConfirm)) {
// `/login/` is the same page as `/login` (go_router ignores the trailing
// slash), so it is not allowed either.
final path = uri.path.length > 1
? uri.path.replaceFirst(RegExp(r'/+$'), '')
: uri.path;
if (_neverFromPaths.contains(path) ||
path.startsWith(AppRoutes.authConfirm)) {
return null;
}
return from;

View file

@ -85,6 +85,9 @@ class RegisterController extends _$RegisterController {
if (state.isSubmitting) {
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();
if (!ref.mounted) {
return false;
@ -100,13 +103,13 @@ class RegisterController extends _$RegisterController {
final invalid = state.firstInvalidField;
if (invalid != null) {
state = state.copyWith(
status: RegisterStatus.editing,
focusField: invalid,
focusRequest: state.focusRequest + 1,
);
return false;
}
state = state.copyWith(status: RegisterStatus.submitting);
final input = ref.read(registrationInputFactoryProvider)(
email: EmailFormat.normalize(state.email),
password: state.password,
@ -155,7 +158,7 @@ class RegisterController extends _$RegisterController {
LegalDocument.terms => config.legalTermsUrl,
};
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) {
if (uri == null || !isOpenableExternalUrl(uri)) {
_log.warning('invalid legal url for ${document.name}');
return false;
}

View file

@ -45,7 +45,7 @@ final class RegisterControllerProvider
}
String _$registerControllerHash() =>
r'25930050b4b5fbaff535c3a09abe4ca15c860759';
r'78a2a99ae36292286f26aff1f9910504bc464636';
/// 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 _displayNameFocus = FocusNode();
/// The user is typing in the password field (see `liveError` below).
bool _passwordTyping = false;
@override
void initState() {
super.initState();
@ -54,6 +57,10 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
);
}
_onFocusChange(_passwordFocus, RegisterField.password);
if (!_passwordFocus.hasFocus && _passwordTyping && mounted) {
// Field left: the length hint may be announced now.
setState(() => _passwordTyping = false);
}
});
_displayNameFocus.addListener(
() => _onFocusChange(_displayNameFocus, RegisterField.displayName),
@ -167,7 +174,15 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
enabled: !busy,
ruleHint: l10n.authPasswordRule,
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),
AppTextField(
@ -258,6 +273,13 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
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) {
if (state.serverPasswordReasons case final reasons?) {
return weakPasswordMessage(l10n, reasons);

View file

@ -20,7 +20,7 @@
"authErrorNetwork": "Keine Verbindung. Für diesen Schritt brauchst du Internet.",
"authErrorUnexpected": "Etwas ist schiefgelaufen. Bitte versuche es erneut.",
"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.",
"authPasswordRule": "Mindestens 10 Zeichen. Kein sehr verbreitetes Passwort.",
"authPasswordRequired": "Gib ein Passwort ein.",

View file

@ -35,7 +35,7 @@
"authErrorNetwork": "No connection. You need internet access for this step.",
"authErrorUnexpected": "Something went wrong. Please 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": {
"placeholders": {
"missing": {

View file

@ -221,7 +221,7 @@ abstract class AppLocalizations {
/// No description provided for @authPasswordTooShort.
///
/// 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);
/// No description provided for @authPasswordCommon.

View file

@ -84,7 +84,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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

View file

@ -81,7 +81,13 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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

View file

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shuffle/app.dart';
import 'package:shuffle/core/config/app_config.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/routing/url_strategy.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 {
WidgetsFlutterBinding.ensureInitialized();
AppLogger.configure();
registerThirdPartyLicenses();
installGlobalErrorHandlers();
configurePathUrlStrategy();

View file

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

View file

@ -23,6 +23,7 @@ dependencies:
json_annotation: 4.12.0
logging: 1.3.0
intl: 0.20.3
http: 1.6.0
url_launcher: 6.3.3
dev_dependencies:
@ -40,3 +41,4 @@ flutter:
generate: true
assets:
- 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/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_checkbox_field.dart';
@ -79,11 +78,13 @@ void main() {
await tester.tap(find.text('terms'));
expect(opened, 1);
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);
});
testWidgets('exposes checked state, full label and link actions', (
testWidgets('exposes checked state and full label on the checkbox node', (
tester,
) async {
final handle = tester.ensureSemantics();
@ -107,12 +108,43 @@ void main() {
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
customActions: [const CustomSemanticsAction(label: 'terms')],
),
);
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', (
tester,
) async {

View file

@ -77,6 +77,26 @@ void main() {
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 {
final controller = TextEditingController();
addTearDown(controller.dispose);

View file

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
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/domain/auth_failure.dart';
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"}';
class SocketException implements Exception {}
class ClientException implements Exception {}
void main() {
group('Spec #1 § 4.4 error mapping', () {
test('KTO-01 Kriterium 7: invalid_credentials', () {
@ -234,11 +232,15 @@ void main() {
const AuthFailure.network(),
);
expect(
mapAuthError(SocketException(), AuthOperation.signIn),
mapAuthError(const SocketException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(ClientException(), AuthOperation.signIn),
mapAuthError(http.ClientException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
expect(
mapAuthError(const HandshakeException('x'), AuthOperation.signIn),
const AuthFailure.network(),
);
});

View file

@ -134,6 +134,13 @@ void main() {
});
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', () {
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', () {
test(
'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:shuffle/core/platform/url_opener.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/registration_input.dart';
import 'package:shuffle/l10n/app_localizations_de.dart';
@ -164,6 +165,33 @@ void main() {
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 {
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 |
|---|---|---|
| `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. |
| `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. |
@ -209,6 +209,10 @@ teilt – Entscheidung beim flutter-dev (A2).
- 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
`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.
- Test je Screen mit `textScaler` 2.0 (Android 320 x 640), `androidTapTargetGuideline`,
`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.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.
@ -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).
- `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)
@ -291,12 +291,13 @@ Alle Formulare: Eingaben bleiben bei jedem Fehler erhalten (Querschnitt Zuständ
- `supabaseClientProvider` (Fundament), `authRepositoryProvider`, `commonPasswordListProvider` (FutureProvider, lazy).
- `authStatusProvider` (Stream) mit Abbildung der `AuthChangeEvent`s: `signedIn`/`tokenRefreshed`/`userUpdated` → `signedIn`, `passwordRecovery` → `passwordRecovery`, `signedOut` → `signedOut`.
- **Abweichung (umgesetzt in `AuthStatusTracker`, bewusst):** Während einer laufenden Recovery heben `tokenRefreshed` und `signedIn` den Status `passwordRecovery` **nicht** auf. Der Guard endet nur mit `userUpdated` (Passwort geändert), `signedOut` oder dem Wegfall der Sitzung. Grund: Ein Token-Refresh (oder ein erneutes `signedIn` beim Wiederherstellen der Sitzung) im offenen Reset-Screen würde den Nutzer sonst ohne neues Passwort aus dem Guard entlassen. Der Zustand lebt weiter nur im Prozess (5.4, R12).
- 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)
- 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.
### 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.
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").
### 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.