import 'dart:developer'; import 'package:flutter/foundation.dart'; import '../../api/demo/demo_mode.dart'; import '../../api/errors/error_mapper.dart'; import '../../api/marianumconnect/auth/device_token_name.dart'; import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_request.dart'; import '../../api/marianumconnect/queries/auth_guardian/auth_guardian_verify.dart'; import '../../api/marianumconnect/queries/auth_guardian/guardian_login_exception.dart'; import '../../auth_link/device_binding.dart'; import '../../auth_link/guardian_login_link.dart'; import '../../auth_link/pending_guardian_request.dart'; import '../../session/session.dart'; import '../../session/session_manager.dart'; import '../../widget_data/widget_sync.dart'; enum GuardianLoginStep { enterEmail, enterCode } /// Drives the passwordless guardian login: request a mail, then finish with /// the mailed code or link. The running request is persisted so the flow /// survives the app being killed while the user reads the mail. class GuardianLoginController extends ChangeNotifier { final AuthGuardianRequest _request; final AuthGuardianVerify _verify; final PendingGuardianRequestStore _store; final Future Function(Session session) _signIn; final Future Function() _tokenName; final DateTime Function() _now; GuardianLoginController({ AuthGuardianRequest? request, AuthGuardianVerify? verify, PendingGuardianRequestStore store = const PendingGuardianRequestStore(), Future Function(Session session)? signIn, Future Function()? tokenName, DateTime Function()? now, }) : _request = request ?? AuthGuardianRequest(), _verify = verify ?? AuthGuardianVerify(), _store = store, _signIn = signIn ?? _defaultSignIn, _tokenName = tokenName ?? DeviceTokenName.resolve, _now = now ?? DateTime.now; GuardianLoginStep _step = GuardianLoginStep.enterEmail; PendingGuardianRequest? _pending; bool _loading = false; String? _errorMessage; String? _errorDetails; GuardianLoginStep get step => _step; PendingGuardianRequest? get pending => _pending; bool get loading => _loading; String? get errorMessage => _errorMessage; String? get errorDetails => _errorDetails; bool canResend() => resendCooldown() == Duration.zero; /// Restzeit, bis [resend] wieder erlaubt ist. Duration resendCooldown() { final pending = _pending; if (pending == null) return Duration.zero; final remaining = pending.resendAvailableAt.difference(_now()); return remaining.isNegative ? Duration.zero : remaining; } /// Picks up a request started before the app was closed. Future restore() async { final stored = await _store.read(); if (stored == null) return; if (stored.isExpired(_now())) { await _store.clear(); return; } _pending = stored; _step = GuardianLoginStep.enterCode; notifyListeners(); } /// Sends the login mail. Returns true when the user is already signed in /// (demo address), false when the code step follows or the request failed. Future requestCode(String email) async { final normalized = email.trim().toLowerCase(); if (DemoMode.matchesGuardian(normalized)) { var signedIn = false; await _run(() async { await _signIn(GuardianSession(email: normalized, isDemo: true)); signedIn = true; }); return signedIn; } await _run(() async { final secret = DeviceBinding.generateSecret(); final response = await _request.run( email: normalized, deviceChallenge: DeviceBinding.challengeFor(secret), tokenName: await _tokenName(), ); final pending = PendingGuardianRequest( requestId: response.requestId, email: normalized, deviceSecret: secret, expiresAt: response.expiresAt, resendAvailableAt: response.resendAvailableAt, codeLength: response.codeLength, ); await _store.write(pending); _pending = pending; _step = GuardianLoginStep.enterCode; }); return false; } Future resend() async { final pending = _pending; if (pending == null || !canResend()) return; await requestCode(pending.email); } /// Mail clients and autofill may insert spaces into the code. static String normalizeCode(String code) => code.replaceAll(RegExp(r'\s'), ''); Future submitCode(String code) => _complete(code: normalizeCode(code)); /// Completes the login from a mail link. A link belonging to another /// request (other device, or an older mail) cannot be verified here. Future submitLink(GuardianLoginLink link) { if (_pending?.requestId != link.requestId) { _errorMessage = GuardianLoginException.messageFor( GuardianLoginError.deviceMismatch, ); _errorDetails = null; notifyListeners(); return Future.value(false); } return _complete(linkToken: link.linkToken); } /// A mail link that does not belong to the server the app talks to (a live /// link while the app points at beta). Without this the tap would do nothing /// at all. void rejectForeignLink() { _errorMessage = 'Dieser Anmeldelink gehört zu einem anderen Server als dem, mit dem ' 'die App gerade verbunden ist. Bitte gib den Code aus der E-Mail ein.'; _errorDetails = null; notifyListeners(); } /// Abandons the running request, e.g. to correct a mistyped address. Future changeEmail() async { await _store.clear(); _pending = null; _step = GuardianLoginStep.enterEmail; _errorMessage = null; _errorDetails = null; notifyListeners(); } Future _complete({String? code, String? linkToken}) async { final pending = _pending; if (pending == null) { _errorMessage = GuardianLoginException.messageFor( GuardianLoginError.requestExpired, ); _errorDetails = null; _step = GuardianLoginStep.enterEmail; notifyListeners(); return false; } var signedIn = false; await _run(() async { await _verify.run( requestId: pending.requestId, deviceVerifier: pending.deviceSecret, tokenName: await _tokenName(), code: code, linkToken: linkToken, ); await _store.clear(); await _signIn(GuardianSession(email: pending.email)); signedIn = true; }); return signedIn; } Future _run(Future Function() body) async { if (_loading) return; _loading = true; _errorMessage = null; _errorDetails = null; notifyListeners(); try { await body(); } on GuardianLoginException catch (e) { _errorMessage = e.userMessage; _errorDetails = e.technicalDetails; // These end the request for good; only a new mail helps. if (e.error case GuardianLoginError.requestExpired || GuardianLoginError.requestConsumed || GuardianLoginError.tooManyAttempts) { await _store.clear(); _pending = null; _step = GuardianLoginStep.enterEmail; } } catch (e) { log('Guardian login failed: $e'); _errorMessage = errorToUserMessage(e); _errorDetails = errorToTechnicalDetails(e); } finally { _loading = false; notifyListeners(); } } static Future _defaultSignIn(Session session) async { // Sign in first: the one-time code is already spent at this point, so a // failing widget reset must not cost the session (the user would have to // request a fresh mail for a login that actually succeeded). await SessionManager().signIn(session); // Drop any widget snapshot of a previous account. try { await WidgetSync.clear(); await WidgetSync.triggerUpdate(); } on Object catch (e) { log('Guardian login: widget reset failed: $e'); } } }