import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'package:flutter/material.dart'; import '../../api/errors/error_mapper.dart'; import '../../api/marianumcloud/app_password/delete_app_password.dart'; import '../../api/marianumcloud/login_flow/login_flow_api.dart'; import '../../model/account_data.dart'; import '../../routing/app_routes.dart'; import '../../widget/app_progress_indicator.dart'; /// Die beiden Durchläufe des Login Flow v2: Der erste liefert das allgemeine /// App-Passwort (voller Browser-Login inkl. 2FA), der zweite das /// Talk-App-Passwort für die zweite Push-Subscription — der Browser hat dann /// bereits eine Session, es bleibt nur der „Zugriff gewähren"-Tipp. enum _FlowStep { primary, talk } /// Runs the Nextcloud Login Flow v2: opens the browser login, polls until the /// user confirmed it there (2FA happens inside the browser) and adopts the /// returned app password via [AccountData.setLoginFlow]. A second, skippable /// pass mints the Talk app password so flow accounts keep BOTH push /// subscriptions (see PushRegistrationType). Pops `true` once the primary /// credential was adopted, `false`/`null` when the user backs out before that. class NextcloudLoginFlowPage extends StatefulWidget { const NextcloudLoginFlowPage({super.key}); @override State createState() => _NextcloudLoginFlowPageState(); } class _NextcloudLoginFlowPageState extends State with WidgetsBindingObserver { static const _pollInterval = Duration(seconds: 3); // Serverseitig verfällt der Flow-Token nach 20 Minuten — danach würde der // Poll für immer 404 liefern, also vorher mit klarer Meldung abbrechen. static const _flowTimeout = Duration(minutes: 15); final LoginFlowApi _api = LoginFlowApi(); _FlowStep _step = _FlowStep.primary; LoginFlowInit? _flow; Timer? _timer; DateTime? _startedAt; bool _polling = false; bool _finished = false; String? _error; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); unawaited(_start()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _timer?.cancel(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { // Der Nutzer kommt gerade aus dem Browser zurück — sofort pollen statt // bis zu einem Intervall zu warten. if (state == AppLifecycleState.resumed) unawaited(_poll()); } Future _start() async { _timer?.cancel(); setState(() { _error = null; _flow = null; }); try { final flow = await _api.start(); if (!mounted) return; setState(() => _flow = flow); _startedAt = DateTime.now(); _timer = Timer.periodic(_pollInterval, (_) => _poll()); unawaited(AppRoutes.openExternalUrl(flow.loginUrl)); } catch (e) { if (!mounted) return; setState(() => _error = errorToUserMessage(e)); } } Future _poll() async { final flow = _flow; if (flow == null || _polling || _finished || _error != null) return; final startedAt = _startedAt; if (startedAt != null && DateTime.now().difference(startedAt) > _flowTimeout) { _timer?.cancel(); setState( () => _error = 'Zeitüberschreitung — die Anmeldung im Browser wurde nicht abgeschlossen.', ); return; } _polling = true; try { final credentials = await _api.poll(flow); if (credentials == null || _finished || !mounted) return; if (!LoginFlowApi.loginNameMatches( expected: AccountData().getUsername(), actual: credentials.loginName, )) { _timer?.cancel(); // Das versehentlich für das fremde Konto ausgestellte App-Passwort // nicht liegen lassen. unawaited(_revokeForeignAppPassword(credentials)); setState( () => _error = 'Im Browser wurde ein anderes Konto angemeldet („${credentials.loginName}“). ' 'Bitte versuche es erneut mit deinem Konto.', ); return; } switch (_step) { case _FlowStep.primary: await AccountData().setLoginFlow(credentials.appPassword); if (!mounted) return; // Zweite Freigabe direkt anstoßen: die Browser-Session besteht // bereits, es fehlt nur noch der Grant-Tipp. setState(() => _step = _FlowStep.talk); unawaited(_start()); case _FlowStep.talk: _finished = true; _timer?.cancel(); await AccountData().setAppPasswordTalk(credentials.appPassword); if (!mounted) return; Navigator.of(context).pop(true); } } on Object catch (e) { // Transienter Poll-Fehler (z.B. kurz offline) — der nächste Tick // versucht es erneut. log('Login flow poll failed (retrying): $e'); } finally { _polling = false; } } /// Der Talk-Schritt ist optional: ohne zweites App-Passwort funktioniert /// alles außer den allgemeinen Nextcloud-Pushes (Talk-Push bleibt erhalten). void _skipTalkStep() { _finished = true; _timer?.cancel(); Navigator.of(context).pop(true); } static Future _revokeForeignAppPassword( LoginFlowCredentials credentials, ) async { try { final basic = base64Encode( utf8.encode('${credentials.loginName}:${credentials.appPassword}'), ); await DeleteAppPassword().run(authorizationHeader: 'Basic $basic'); } on Object catch (e) { log('Login flow: could not revoke foreign app password: $e'); } } @override Widget build(BuildContext context) { final theme = Theme.of(context); final flow = _flow; final error = _error; final isTalkStep = _step == _FlowStep.talk; // Ab dem Talk-Schritt ist das primäre App-Passwort bereits übernommen — // Zurück heißt dann „überspringen" (pop true), nicht „Login abbrechen": // die Aufrufer würden bei false den kompletten Login zurückrollen. return PopScope( canPop: !isTalkStep, onPopInvokedWithResult: (didPop, _) { if (!didPop && !_finished) _skipTalkStep(); }, child: Scaffold( appBar: AppBar(title: const Text('Nextcloud-Anmeldung')), body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Icon( isTalkStep ? Icons.notifications_active_outlined : Icons.verified_user_outlined, size: 56, color: theme.colorScheme.primary, ), const SizedBox(height: 16), Text( isTalkStep ? 'Fast geschafft!' : 'Bestätigung erforderlich', textAlign: TextAlign.center, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Text( isTalkStep ? 'Damit Benachrichtigungen vollständig ankommen, braucht ' 'die App eine zweite Freigabe. Du bist im Browser ' 'bereits angemeldet — es genügt ein Tipp auf ' '„Zugriff gewähren“.' : 'Dein Konto ist zusätzlich geschützt (z.B. durch ' 'Zwei-Faktor-Authentifizierung). Schließe die ' 'Anmeldung im Browser ab — danach geht es hier ' 'automatisch weiter.', textAlign: TextAlign.center, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 24), if (error != null) ...[ Text( error, textAlign: TextAlign.center, style: TextStyle(color: theme.colorScheme.error), ), const SizedBox(height: 16), FilledButton( onPressed: _start, child: const Text('Erneut versuchen'), ), ] else if (flow == null) ...[ const Center(child: AppProgressIndicator.medium()), ] else ...[ FilledButton.icon( icon: const Icon(Icons.open_in_browser), onPressed: () => unawaited(AppRoutes.openExternalUrl(flow.loginUrl)), label: Text( isTalkStep ? 'Freigabe im Browser bestätigen' : 'Anmeldung im Browser öffnen', ), ), const SizedBox(height: 20), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ const AppProgressIndicator.small(), const SizedBox(width: 10), Text( 'Warte auf Bestätigung im Browser…', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ], if (isTalkStep) ...[ const SizedBox(height: 12), TextButton( onPressed: _skipTalkStep, child: const Text('Überspringen'), ), ], ], ), ), ), ), ), ), ); } }