Shuffle/app/lib/features/auth/presentation/register/register_controller.dart
cschlaefke 239d0e1ac2
All checks were successful
CI / flutter (pull_request) Successful in 1m16s
CI / web-build (pull_request) Successful in 53s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 32s
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>
2026-11-03 00:47:42 +01:00

167 lines
5.4 KiB
Dart

import 'dart:async';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shuffle/core/config/app_config_provider.dart';
import 'package:shuffle/core/logging/app_logger.dart';
import 'package:shuffle/core/platform/url_opener.dart';
import 'package:shuffle/features/auth/domain/auth_failure.dart';
import 'package:shuffle/features/auth/domain/resend_origin.dart';
import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart';
import 'package:shuffle/features/auth/domain/validation/email_format.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart';
import 'package:shuffle/features/auth/presentation/register/register_state.dart';
part 'register_controller.g.dart';
/// Legal texts linked from the consent checkbox.
enum LegalDocument { privacy, terms }
final _log = AppLogger('RegisterController');
/// Logic of the registration form (KTO-01 Kriterien 1 bis 4 und 10).
@riverpod
class RegisterController extends _$RegisterController {
Future<void>? _commonPasswordsLoad;
@override
RegisterState build() => const RegisterState();
void emailChanged(String value) {
state = state.copyWith(email: value, serverEmailInvalid: false);
}
void passwordChanged(String value) {
// The password is checked while typing (Kriterium 3).
state = state.copyWith(
password: value,
visibleFields: {...state.visibleFields, RegisterField.password},
serverPasswordReasons: null,
);
unawaited(loadCommonPasswords());
}
void displayNameChanged(String value) {
state = state.copyWith(displayName: value);
}
void ageConfirmedChanged({required bool value}) {
state = state.copyWith(ageConfirmed: value);
}
void termsAcceptedChanged({required bool value}) {
state = state.copyWith(termsAccepted: value);
}
/// Field left: show its error (text fields only).
void fieldBlurred(RegisterField field) {
if (field == RegisterField.ageConfirmation ||
field == RegisterField.termsAcceptance) {
return;
}
state = state.copyWith(visibleFields: {...state.visibleFields, field});
}
/// Starts loading the list of common passwords (lazy, once). Called when
/// the password field is used.
Future<void> loadCommonPasswords() => _commonPasswordsLoad ??= _load();
Future<void> _load() async {
try {
final list = await ref.read(commonPasswordListProvider.future);
if (!ref.mounted) {
return;
}
state = state.copyWith(commonPasswords: list);
} on Object catch (error) {
// Fallback: only the length rule runs in the client; the server
// enforces the minimum length as well.
_log.warning('common password list unavailable: ${error.runtimeType}');
}
}
/// Validates and submits. Returns `true` if the screen should continue to
/// "check your inbox" (every 200 response, and `emailSendLimited`, F12).
Future<bool> submit() async {
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;
}
state = state.copyWith(
visibleFields: RegisterField.values.toSet(),
bannerFailure: null,
serverPasswordReasons: null,
serverEmailInvalid: false,
focusField: null,
);
final invalid = state.firstInvalidField;
if (invalid != null) {
state = state.copyWith(
status: RegisterStatus.editing,
focusField: invalid,
focusRequest: state.focusRequest + 1,
);
return false;
}
final input = ref.read(registrationInputFactoryProvider)(
email: EmailFormat.normalize(state.email),
password: state.password,
displayName: DisplayNameRule.normalize(state.displayName),
ageConfirmed: state.ageConfirmed,
termsAccepted: state.termsAccepted,
);
final result = await ref.read(authRepositoryProvider).signUp(input);
if (!ref.mounted) {
return false;
}
final failure = result.errorOrNull;
state = state.copyWith(status: RegisterStatus.editing);
if (failure == null || openEmailInputFailureFor(failure) == null) {
return true;
}
_showFailure(failure);
return false;
}
void _showFailure(AuthFailure failure) {
switch (failure) {
case WeakPassword(:final reasons):
state = state.copyWith(
serverPasswordReasons: reasons,
focusField: RegisterField.password,
focusRequest: state.focusRequest + 1,
);
case InvalidEmail():
state = state.copyWith(
serverEmailInvalid: true,
focusField: RegisterField.email,
focusRequest: state.focusRequest + 1,
);
default:
state = state.copyWith(bannerFailure: failure);
}
}
/// Opens a legal text in the browser. `false` if that did not work.
Future<bool> openLegalDocument(LegalDocument document) async {
final config = ref.read(appConfigProvider);
final url = switch (document) {
LegalDocument.privacy => config.legalPrivacyUrl,
LegalDocument.terms => config.legalTermsUrl,
};
final uri = Uri.tryParse(url);
if (uri == null || !isOpenableExternalUrl(uri)) {
_log.warning('invalid legal url for ${document.name}');
return false;
}
return ref.read(urlOpenerProvider)(uri);
}
}