fix(auth): address review findings for registration
- 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:
parent
e632da2344
commit
239d0e1ac2
39 changed files with 489 additions and 92 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
18
app/lib/core/licenses/third_party_licenses.dart
Normal file
18
app/lib/core/licenses/third_party_licenses.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
6
app/lib/core/platform/io_error.dart
Normal file
6
app/lib/core/platform/io_error.dart
Normal 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';
|
||||||
7
app/lib/core/platform/io_error_io.dart
Normal file
7
app/lib/core/platform/io_error_io.dart
Normal 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;
|
||||||
2
app/lib/core/platform/io_error_stub.dart
Normal file
2
app/lib/core/platform/io_error_stub.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// Web and other platforms without `dart:io`: no `dart:io` network errors.
|
||||||
|
bool isIoNetworkError(Object error) => false;
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,4 @@ final class UrlOpenerProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$urlOpenerHash() => r'f5f1e650e073776595fac2981548a91f815c66ca';
|
String _$urlOpenerHash() => r'e86eb081c587907d3c162ed619b42abbbfb195a5';
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,4 @@ final class AppRouterProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$appRouterHash() => r'f4f391f8abc008f4a7b1a88149822bda24f42137';
|
String _$appRouterHash() => r'39ea53d71fba02f9e9cc5e42df61144e55eca3d9';
|
||||||
|
|
|
||||||
26
app/lib/core/routing/volatile_extra_codec.dart
Normal file
26
app/lib/core/routing/volatile_extra_codec.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
26
app/test/core/licenses/third_party_licenses_test.dart
Normal file
26
app/test/core/licenses/third_party_licenses_test.dart
Normal 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'));
|
||||||
|
});
|
||||||
|
}
|
||||||
42
app/test/core/platform/url_opener_test.dart
Normal file
42
app/test/core/platform/url_opener_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
43
app/test/core/routing/volatile_extra_codec_test.dart
Normal file
43
app/test/core/routing/volatile_extra_codec_test.dart
Normal 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')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue