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() { final pending = _pending; return pending != null && !_now().isBefore(pending.resendAvailableAt); } /// 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)) { await _signIn(GuardianSession(email: normalized, isDemo: true)); return true; } 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); } /// 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) 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 { // Drop any widget snapshot of a previous account before the new one loads. await WidgetSync.clear(); await WidgetSync.triggerUpdate(); await SessionManager().signIn(session); } }