282 lines
10 KiB
Dart
282 lines
10 KiB
Dart
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<NextcloudLoginFlowPage> createState() => _NextcloudLoginFlowPageState();
|
|
}
|
|
|
|
class _NextcloudLoginFlowPageState extends State<NextcloudLoginFlowPage>
|
|
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<void> _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<void> _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<void> _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'),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|