feat(auth): KTO-01 Registrierung (01a) – Screens, Auth-Domäne, Formular-Komponenten (2/4) #34
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 |
|
||||
| `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
|
||||
|
|
|
|||
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');
|
||||
|
||||
/// 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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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/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(),
|
||||
|
|
|
|||
|
|
@ -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/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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -334,7 +334,7 @@ packages:
|
|||
source: hosted
|
||||
version: "2.3.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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/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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue