From ffb8623ddcd9941bdb69354e7fb8e9097cde493b Mon Sep 17 00:00:00 2001 From: jld3103 Date: Sun, 9 Jul 2023 18:01:28 +0200 Subject: [PATCH 1/4] neon: Add qrcode login --- .../app/linux/flutter/generated_plugins.cmake | 1 + packages/app/pubspec.lock | 200 ++++++++++++++++++ packages/neon/neon/lib/l10n/en.arb | 4 +- .../neon/neon/lib/l10n/localizations.dart | 18 +- .../neon/neon/lib/l10n/localizations_en.dart | 8 +- packages/neon/neon/lib/src/pages/login.dart | 34 ++- .../neon/neon/lib/src/pages/login_qrcode.dart | 66 ++++++ packages/neon/neon/lib/src/router.dart | 13 ++ packages/neon/neon/lib/src/router.g.dart | 19 ++ .../neon/neon/lib/src/utils/exceptions.dart | 2 + .../neon/neon/lib/src/widgets/exception.dart | 6 + packages/neon/neon/pubspec.yaml | 1 + 12 files changed, 366 insertions(+), 6 deletions(-) create mode 100644 packages/neon/neon/lib/src/pages/login_qrcode.dart diff --git a/packages/app/linux/flutter/generated_plugins.cmake b/packages/app/linux/flutter/generated_plugins.cmake index 3932809f..7180e5e3 100644 --- a/packages/app/linux/flutter/generated_plugins.cmake +++ b/packages/app/linux/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_zxing ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 750e7266..37d5efde 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + ansi_styles: + dependency: transitive + description: + name: ansi_styles + sha256: "9c656cc12b3c27b17dd982b2cc5c0cfdfbdabd7bc8f3ae5e8542d9867b47ce8a" + url: "https://pub.dev" + source: hosted + version: "0.3.2+1" archive: dependency: transitive description: @@ -65,6 +73,46 @@ packages: url: "https://pub.dev" source: hosted version: "8.6.1" + camera: + dependency: transitive + description: + name: camera + sha256: ebebead3d5ec3d148249331d751d462d7e8c98102b8830a9b45ec96a2bd4333f + url: "https://pub.dev" + source: hosted + version: "0.10.5+2" + camera_android: + dependency: transitive + description: + name: camera_android + sha256: f43d07f9d7228ea1ca87d22e30881bd68da4b78484a1fbd1f1408b412a41cefb + url: "https://pub.dev" + source: hosted + version: "0.10.8+3" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "1a416e452b30955b392f4efbf23291d3f2ba3660a85e1628859eb62d2a2bab26" + url: "https://pub.dev" + source: hosted + version: "0.9.13+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "60fa0bb62a4f3bf3a7c413e31e4cd01b69c779ccc8e4668904a24581b86c316b" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: bcbd775fb3a9d51cc3ece899d54ad66f6306410556bac5759f78e13f9228841f + url: "https://pub.dev" + source: hosted + version: "0.3.1+4" characters: dependency: transitive description: @@ -73,6 +121,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + cli_launcher: + dependency: transitive + description: + name: cli_launcher + sha256: "5e7e0282b79e8642edd6510ee468ae2976d847a0a29b3916e85f5fa1bfe24005" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + url: "https://pub.dev" + source: hosted + version: "0.4.0" clock: dependency: transitive description: @@ -89,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.1" + conventional_commit: + dependency: transitive + description: + name: conventional_commit + sha256: dec15ad1118f029c618651a4359eb9135d8b88f761aa24e4016d061cd45948f2 + url: "https://pub.dev" + source: hosted + version: "0.6.0+1" convert: dependency: transitive description: @@ -344,11 +424,27 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_zxing: + dependency: transitive + description: + name: flutter_zxing + sha256: b25efe5ac91fe7a51aa8bfea7aca7435285b911b8758b7da38b7f037bad62c35 + url: "https://pub.dev" + source: hosted + version: "1.1.2" fuchsia_remote_debug_protocol: dependency: transitive description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" go_router: dependency: transitive description: @@ -357,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.0.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + url: "https://pub.dev" + source: hosted + version: "2.3.1" html: dependency: transitive description: @@ -474,6 +578,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.18.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" js: dependency: transitive description: @@ -538,6 +650,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.7296" + melos: + dependency: transitive + description: + name: melos + sha256: ccbb6ecd8bb3f08ae8f9ce22920d816bff325a98940c845eda0257cd395503ac + url: "https://pub.dev" + source: hosted + version: "3.1.0" menu_base: dependency: transitive description: @@ -562,6 +682,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + mustache_template: + dependency: transitive + description: + name: mustache_template + sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c + url: "https://pub.dev" + source: hosted + version: "2.0.0" neon: dependency: "direct main" description: @@ -789,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.7.3" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" process: dependency: transitive description: @@ -797,6 +933,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.4" + prompts: + dependency: transitive + description: + name: prompts + sha256: "3773b845e85a849f01e793c4fc18a45d52d7783b4cb6c0569fad19f9d0a774a1" + url: "https://pub.dev" + source: hosted + version: "2.0.0" provider: dependency: transitive description: @@ -805,6 +949,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.5" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "42890302ab2672adf567dc2b20e55b4ecc29d7e19c63b6b98143ab68dd717d3a" + url: "https://pub.dev" + source: hosted + version: "0.2.4" + pubspec: + dependency: transitive + description: + name: pubspec + sha256: f534a50a2b4d48dc3bc0ec147c8bd7c304280fff23b153f3f11803c4d49d927e + url: "https://pub.dev" + source: hosted + version: "2.3.0" queue: dependency: transitive description: @@ -845,6 +1013,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" rxdart: dependency: transitive description: @@ -1009,6 +1185,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" string_scanner: dependency: transitive description: @@ -1105,6 +1289,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" url_launcher: dependency: transitive description: @@ -1354,6 +1546,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "1579d4a0340a83cf9e4d580ea51a16329c916973bffd5bd4b45e911b25d46bfd" + url: "https://pub.dev" + source: hosted + version: "2.1.1" sdks: dart: ">=3.0.0 <4.0.0" flutter: ">=3.10.4" diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index e9535306..2372a31e 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -10,7 +10,8 @@ "loginOpenAgain": "Open again", "loginSwitchToBrowserWindow": "Please switch to the browser window that just opened and proceed there", "loginWorksWith": "works with", - "loginRestart": "Restart login", + "loginUsingQrcode": "Login using a QR code", + "loginUsingServerAddress": "Login using the server address", "loginCheckingServerVersion": "Checking server version", "loginSupportedServerVersion": "Supported server version: {version}", "@loginSupportedServerVersion": { @@ -66,6 +67,7 @@ }, "errorEmptyField": "This field can not be empty", "errorInvalidURL": "Invalid URL provided", + "errorInvalidQrcode": "Invalid QR-Code provided", "actionYes": "Yes", "actionNo": "No", "actionClose": "Close", diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index 4bcfa441..647b4cfa 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -119,11 +119,17 @@ abstract class AppLocalizations { /// **'works with'** String get loginWorksWith; - /// No description provided for @loginRestart. + /// No description provided for @loginUsingQrcode. /// /// In en, this message translates to: - /// **'Restart login'** - String get loginRestart; + /// **'Login using a QR code'** + String get loginUsingQrcode; + + /// No description provided for @loginUsingServerAddress. + /// + /// In en, this message translates to: + /// **'Login using the server address'** + String get loginUsingServerAddress; /// No description provided for @loginCheckingServerVersion. /// @@ -245,6 +251,12 @@ abstract class AppLocalizations { /// **'Invalid URL provided'** String get errorInvalidURL; + /// No description provided for @errorInvalidQrcode. + /// + /// In en, this message translates to: + /// **'Invalid QR-Code provided'** + String get errorInvalidQrcode; + /// No description provided for @actionYes. /// /// In en, this message translates to: diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index 5142f03b..90ba1a2d 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -36,7 +36,10 @@ class AppLocalizationsEn extends AppLocalizations { String get loginWorksWith => 'works with'; @override - String get loginRestart => 'Restart login'; + String get loginUsingQrcode => 'Login using a QR code'; + + @override + String get loginUsingServerAddress => 'Login using the server address'; @override String get loginCheckingServerVersion => 'Checking server version'; @@ -111,6 +114,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorInvalidURL => 'Invalid URL provided'; + @override + String get errorInvalidQrcode => 'Invalid QR-Code provided'; + @override String get actionYes => 'Yes'; diff --git a/packages/neon/neon/lib/src/pages/login.dart b/packages/neon/neon/lib/src/pages/login.dart index 48e95e7d..84d4945a 100644 --- a/packages/neon/neon/lib/src/pages/login.dart +++ b/packages/neon/neon/lib/src/pages/login.dart @@ -1,10 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/platform/platform.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/validators.dart'; import 'package:neon/src/widgets/nextcloud_logo.dart'; +import 'package:provider/provider.dart'; class LoginPage extends StatefulWidget { const LoginPage({ @@ -52,6 +56,8 @@ class _LoginPageState extends State { @override Widget build(final BuildContext context) { final branding = Branding.of(context); + final platform = Provider.of(context, listen: false); + return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( @@ -72,7 +78,7 @@ class _LoginPageState extends State { style: Theme.of(context).textTheme.titleLarge, ), const SizedBox( - height: 30, + height: 20, ), if (branding.showLoginWithNextcloud) ...[ Text(AppLocalizations.of(context).loginWorksWith), @@ -81,6 +87,32 @@ class _LoginPageState extends State { ), ], const NextcloudLogo(), + const SizedBox( + height: 40, + ), + if (platform.canUseCamera) ...[ + ExcludeSemantics( + child: Center( + child: Text(AppLocalizations.of(context).loginUsingQrcode), + ), + ), + IconButton( + tooltip: AppLocalizations.of(context).loginUsingQrcode, + icon: const Icon( + Icons.qr_code_scanner, + size: 50, + ), + onPressed: () => unawaited(const LoginQrcodeRoute().push(context)), + ), + const SizedBox( + height: 20, + ), + ExcludeSemantics( + child: Center( + child: Text(AppLocalizations.of(context).loginUsingServerAddress), + ), + ), + ], Form( key: _formKey, child: TextFormField( diff --git a/packages/neon/neon/lib/src/pages/login_qrcode.dart b/packages/neon/neon/lib/src/pages/login_qrcode.dart new file mode 100644 index 00000000..e1810175 --- /dev/null +++ b/packages/neon/neon/lib/src/pages/login_qrcode.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_zxing/flutter_zxing.dart'; +import 'package:neon/src/router.dart'; +import 'package:neon/src/utils/exceptions.dart'; +import 'package:neon/src/widgets/exception.dart'; + +class LoginQrcodePage extends StatefulWidget { + const LoginQrcodePage({ + super.key, + }); + + @override + State createState() => _LoginQrcodePageState(); +} + +class _LoginQrcodePageState extends State { + final _urlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$'); + String? _lastErrorURL; + + @override + Widget build(final BuildContext context) => Scaffold( + appBar: AppBar(), + body: ReaderWidget( + codeFormat: Format.qrCode, + showGallery: false, + showToggleCamera: false, + showScannerOverlay: false, + tryHarder: true, + cropPercent: 0, + scanDelaySuccess: const Duration(seconds: 3), + onScan: (final code) async { + String? url; + try { + url = code.text; + if (url == null) { + throw InvalidQrcodeException(); + } + final match = _urlRegex.allMatches(url).single; + if (match.groupCount != 3) { + throw InvalidQrcodeException(); + } + final loginName = match.group(1)!; + final password = match.group(2)!; + final serverURL = match.group(3)!; + + final result = await LoginCheckServerStatusRoute(serverURL: serverURL).push(context); + if ((result ?? false) && mounted) { + LoginCheckAccountRoute( + serverURL: serverURL, + loginName: loginName, + password: password, + ).pushReplacement(context); + } + } catch (e, s) { + if (_lastErrorURL != url) { + debugPrint(e.toString()); + debugPrint(s.toString()); + + _lastErrorURL = url; + NeonException.showSnackbar(context, e); + } + } + }, + ), + ); +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index cad5b2af..f09f8924 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -10,6 +10,7 @@ import 'package:neon/src/pages/login.dart'; import 'package:neon/src/pages/login_check_account.dart'; import 'package:neon/src/pages/login_check_server_status.dart'; import 'package:neon/src/pages/login_flow.dart'; +import 'package:neon/src/pages/login_qrcode.dart'; import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/utils/stream_listenable.dart'; @@ -105,6 +106,10 @@ class HomeRoute extends GoRouteData { path: 'flow/:serverURL', name: 'loginFlow', ), + TypedGoRoute( + path: 'qrcode', + name: 'loginQrcode', + ), TypedGoRoute( path: 'check/server/:serverURL', name: 'checkServerStatus', @@ -137,6 +142,14 @@ class LoginFlowRoute extends GoRouteData { Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverURL); } +@immutable +class LoginQrcodeRoute extends GoRouteData { + const LoginQrcodeRoute(); + + @override + Widget build(final BuildContext context, final GoRouterState state) => const LoginQrcodePage(); +} + @immutable class LoginCheckServerStatusRoute extends GoRouteData { const LoginCheckServerStatusRoute({ diff --git a/packages/neon/neon/lib/src/router.g.dart b/packages/neon/neon/lib/src/router.g.dart index bed7f49b..b823ce90 100644 --- a/packages/neon/neon/lib/src/router.g.dart +++ b/packages/neon/neon/lib/src/router.g.dart @@ -125,6 +125,11 @@ RouteBase get $loginRoute => GoRouteData.$route( name: 'loginFlow', factory: $LoginFlowRouteExtension._fromState, ), + GoRouteData.$route( + path: 'qrcode', + name: 'loginQrcode', + factory: $LoginQrcodeRouteExtension._fromState, + ), GoRouteData.$route( path: 'check/server/:serverURL', name: 'checkServerStatus', @@ -173,6 +178,20 @@ extension $LoginFlowRouteExtension on LoginFlowRoute { void pushReplacement(BuildContext context) => context.pushReplacement(location); } +extension $LoginQrcodeRouteExtension on LoginQrcodeRoute { + static LoginQrcodeRoute _fromState(GoRouterState state) => const LoginQrcodeRoute(); + + String get location => GoRouteData.$location( + '/login/qrcode', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute( serverURL: state.pathParameters['serverURL']!, diff --git a/packages/neon/neon/lib/src/utils/exceptions.dart b/packages/neon/neon/lib/src/utils/exceptions.dart index a92f1d77..cf39f41c 100644 --- a/packages/neon/neon/lib/src/utils/exceptions.dart +++ b/packages/neon/neon/lib/src/utils/exceptions.dart @@ -7,3 +7,5 @@ class MissingPermissionException implements Exception { } class UnableToOpenFileException implements Exception {} + +class InvalidQrcodeException implements Exception {} diff --git a/packages/neon/neon/lib/src/widgets/exception.dart b/packages/neon/neon/lib/src/widgets/exception.dart index 70fd348a..aebf6816 100644 --- a/packages/neon/neon/lib/src/widgets/exception.dart +++ b/packages/neon/neon/lib/src/widgets/exception.dart @@ -128,6 +128,12 @@ class NeonException extends StatelessWidget { ); } + if (exception is InvalidQrcodeException) { + return _ExceptionDetails( + text: AppLocalizations.of(context).errorInvalidQrcode, + ); + } + if (exception is DynamiteApiException) { if (exception.statusCode == 401) { return _ExceptionDetails( diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index e8d7bdaf..b4616eef 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: sdk: flutter flutter_native_splash: ^2.2.19 flutter_svg: ^2.0.5 + flutter_zxing: ^1.1.2 # ^1.2.0 downgrades to image ^3.0.0 which breaks our dependencies. See https://github.com/khoren93/flutter_zxing/issues/94 go_router: ^8.0.3 http: ^0.13.6 intersperse: ^2.0.0 From 51ead3b85d98544719eb2acdeea322db256f6930 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Sun, 9 Jul 2023 18:54:55 +0200 Subject: [PATCH 2/4] neon,app: Handle qrcode login from other apps --- .../android/app/src/main/AndroidManifest.xml | 7 ++ .../neon/neon/lib/src/pages/login_qrcode.dart | 70 +++++++++++++++---- packages/neon/neon/lib/src/router.dart | 34 +++++++++ packages/neon/neon/lib/src/router.g.dart | 23 ++++++ .../neon/neon/lib/src/utils/login_qrcode.dart | 47 +++++++++++++ 5 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 packages/neon/neon/lib/src/utils/login_qrcode.dart diff --git a/packages/app/android/app/src/main/AndroidManifest.xml b/packages/app/android/app/src/main/AndroidManifest.xml index 86bb0637..197488d0 100644 --- a/packages/app/android/app/src/main/AndroidManifest.xml +++ b/packages/app/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,13 @@ + + + + + + + diff --git a/packages/neon/neon/lib/src/pages/login_qrcode.dart b/packages/neon/neon/lib/src/pages/login_qrcode.dart index e1810175..08797c14 100644 --- a/packages/neon/neon/lib/src/pages/login_qrcode.dart +++ b/packages/neon/neon/lib/src/pages/login_qrcode.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_zxing/flutter_zxing.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/exceptions.dart'; +import 'package:neon/src/utils/login_qrcode.dart'; import 'package:neon/src/widgets/exception.dart'; class LoginQrcodePage extends StatefulWidget { @@ -14,7 +17,6 @@ class LoginQrcodePage extends StatefulWidget { } class _LoginQrcodePageState extends State { - final _urlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$'); String? _lastErrorURL; @override @@ -35,22 +37,11 @@ class _LoginQrcodePageState extends State { if (url == null) { throw InvalidQrcodeException(); } - final match = _urlRegex.allMatches(url).single; - if (match.groupCount != 3) { + final match = LoginQrcode.tryParse(url); + if (match == null) { throw InvalidQrcodeException(); } - final loginName = match.group(1)!; - final password = match.group(2)!; - final serverURL = match.group(3)!; - - final result = await LoginCheckServerStatusRoute(serverURL: serverURL).push(context); - if ((result ?? false) && mounted) { - LoginCheckAccountRoute( - serverURL: serverURL, - loginName: loginName, - password: password, - ).pushReplacement(context); - } + await processLoginQrcode(context, match); } catch (e, s) { if (_lastErrorURL != url) { debugPrint(e.toString()); @@ -64,3 +55,52 @@ class _LoginQrcodePageState extends State { ), ); } + +class LoginQrcodeIntermediatePage extends StatefulWidget { + const LoginQrcodeIntermediatePage({ + required this.serverURL, + required this.loginName, + required this.password, + super.key, + }); + + final String serverURL; + final String loginName; + final String password; + + @override + State createState() => _LoginQrcodeIntermediatePageState(); +} + +class _LoginQrcodeIntermediatePageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((final _) { + unawaited( + processLoginQrcode( + context, + LoginQrcode( + server: widget.serverURL, + user: widget.loginName, + password: widget.password, + ), + ), + ); + }); + } + + @override + Widget build(final BuildContext context) => const SizedBox(); +} + +Future processLoginQrcode(final BuildContext context, final LoginQrcode qrcode) async { + final result = await LoginCheckServerStatusRoute(serverURL: qrcode.server).push(context); + if ((result ?? false) && context.mounted) { + LoginCheckAccountRoute( + serverURL: qrcode.server, + loginName: qrcode.user, + password: qrcode.password, + ).pushReplacement(context); + } +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index f09f8924..1a453365 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -13,6 +13,7 @@ import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_qrcode.dart'; import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/settings.dart'; +import 'package:neon/src/utils/login_qrcode.dart'; import 'package:neon/src/utils/stream_listenable.dart'; import 'package:provider/provider.dart'; @@ -30,6 +31,15 @@ class AppRouter extends GoRouter { redirect: (final context, final state) { final account = accountsBloc.activeAccount.valueOrNull; + final loginQrcode = LoginQrcode.tryParse(state.location); + if (loginQrcode != null) { + return LoginQrcodeIntermediateRoute( + serverURL: loginQrcode.server, + loginName: loginQrcode.user, + password: loginQrcode.password, + ).location; + } + // redirect to loginscreen when no account is logged in if (account == null && !state.location.startsWith(const LoginRoute().location)) { return const LoginRoute().location; @@ -110,6 +120,10 @@ class HomeRoute extends GoRouteData { path: 'qrcode', name: 'loginQrcode', ), + TypedGoRoute( + path: 'qrcode/intermediate/:serverURL/:loginName/:password', + name: 'loginQrcodeIntermediate', + ), TypedGoRoute( path: 'check/server/:serverURL', name: 'checkServerStatus', @@ -150,6 +164,26 @@ class LoginQrcodeRoute extends GoRouteData { Widget build(final BuildContext context, final GoRouterState state) => const LoginQrcodePage(); } +@immutable +class LoginQrcodeIntermediateRoute extends GoRouteData { + const LoginQrcodeIntermediateRoute({ + required this.serverURL, + required this.loginName, + required this.password, + }); + + final String serverURL; + final String loginName; + final String password; + + @override + Widget build(final BuildContext context, final GoRouterState state) => LoginQrcodeIntermediatePage( + serverURL: serverURL, + loginName: loginName, + password: password, + ); +} + @immutable class LoginCheckServerStatusRoute extends GoRouteData { const LoginCheckServerStatusRoute({ diff --git a/packages/neon/neon/lib/src/router.g.dart b/packages/neon/neon/lib/src/router.g.dart index b823ce90..c1730833 100644 --- a/packages/neon/neon/lib/src/router.g.dart +++ b/packages/neon/neon/lib/src/router.g.dart @@ -130,6 +130,11 @@ RouteBase get $loginRoute => GoRouteData.$route( name: 'loginQrcode', factory: $LoginQrcodeRouteExtension._fromState, ), + GoRouteData.$route( + path: 'qrcode/intermediate/:serverURL/:loginName/:password', + name: 'loginQrcodeIntermediate', + factory: $LoginQrcodeIntermediateRouteExtension._fromState, + ), GoRouteData.$route( path: 'check/server/:serverURL', name: 'checkServerStatus', @@ -192,6 +197,24 @@ extension $LoginQrcodeRouteExtension on LoginQrcodeRoute { void pushReplacement(BuildContext context) => context.pushReplacement(location); } +extension $LoginQrcodeIntermediateRouteExtension on LoginQrcodeIntermediateRoute { + static LoginQrcodeIntermediateRoute _fromState(GoRouterState state) => LoginQrcodeIntermediateRoute( + serverURL: state.pathParameters['serverURL']!, + loginName: state.pathParameters['loginName']!, + password: state.pathParameters['password']!, + ); + + String get location => GoRouteData.$location( + '/login/qrcode/intermediate/${Uri.encodeComponent(serverURL)}/${Uri.encodeComponent(loginName)}/${Uri.encodeComponent(password)}', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute( serverURL: state.pathParameters['serverURL']!, diff --git a/packages/neon/neon/lib/src/utils/login_qrcode.dart b/packages/neon/neon/lib/src/utils/login_qrcode.dart new file mode 100644 index 00000000..4e2081e2 --- /dev/null +++ b/packages/neon/neon/lib/src/utils/login_qrcode.dart @@ -0,0 +1,47 @@ +import 'package:meta/meta.dart'; + +@internal +class LoginQrcode { + LoginQrcode({ + required this.server, + required this.user, + required this.password, + }); + + static final _loginQrcodeUrlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$'); + static final _loginQrcodePathRegex = RegExp(r'^/user:(.*)&password:(.*)&server:(.*)$'); + + static LoginQrcode parse(final String url) { + for (final regex in [_loginQrcodeUrlRegex, _loginQrcodePathRegex]) { + final matches = regex.allMatches(url); + if (matches.isEmpty) { + continue; + } + + final match = matches.single; + if (match.groupCount != 3) { + continue; + } + + return LoginQrcode( + server: match.group(3)!, + user: match.group(1)!, + password: match.group(2)!, + ); + } + + throw const FormatException(); + } + + static LoginQrcode? tryParse(final String url) { + try { + return parse(url); + } on FormatException { + return null; + } + } + + final String server; + final String user; + final String password; +} From 5be0f1c54c003be75747f08060ed533de83fbc7d Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Thu, 13 Jul 2023 17:49:58 +0200 Subject: [PATCH 3/4] neon: change login routing --- .../neon/neon/lib/src/blocs/accounts.dart | 3 + packages/neon/neon/lib/src/pages/login.dart | 30 +-- .../src/pages/login_check_server_status.dart | 69 ++++-- .../neon/neon/lib/src/pages/login_flow.dart | 45 ++-- .../neon/neon/lib/src/pages/login_qrcode.dart | 58 +---- .../neon/neon/lib/src/pages/settings.dart | 4 +- packages/neon/neon/lib/src/router.dart | 225 ++++++++++++++---- packages/neon/neon/lib/src/router.g.dart | 166 +++++++++---- .../neon/neon/lib/src/widgets/exception.dart | 8 +- 9 files changed, 382 insertions(+), 226 deletions(-) diff --git a/packages/neon/neon/lib/src/blocs/accounts.dart b/packages/neon/neon/lib/src/blocs/accounts.dart index c3a5ef28..03e61d4b 100644 --- a/packages/neon/neon/lib/src/blocs/accounts.dart +++ b/packages/neon/neon/lib/src/blocs/accounts.dart @@ -207,6 +207,9 @@ class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocState return aa; } + /// Whether accounts are logged in. + bool get hasAccounts => activeAccount.value != null; + /// The options for the [activeAccount]. /// /// Convenience method for [getOptionsFor] with the currently active account. diff --git a/packages/neon/neon/lib/src/pages/login.dart b/packages/neon/neon/lib/src/pages/login.dart index 84d4945a..3ad74bae 100644 --- a/packages/neon/neon/lib/src/pages/login.dart +++ b/packages/neon/neon/lib/src/pages/login.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:neon/l10n/localizations.dart'; import 'package:neon/src/platform/platform.dart'; @@ -12,12 +10,9 @@ import 'package:provider/provider.dart'; class LoginPage extends StatefulWidget { const LoginPage({ - this.serverURL, super.key, }); - final String? serverURL; - @override _LoginPageState createState() => _LoginPageState(); } @@ -29,12 +24,6 @@ class _LoginPageState extends State { @override void initState() { super.initState(); - - if (widget.serverURL != null) { - WidgetsBinding.instance.addPostFrameCallback((final _) async { - await _beginLoginFlow(widget.serverURL!); - }); - } } @override @@ -43,16 +32,6 @@ class _LoginPageState extends State { super.dispose(); } - Future _beginLoginFlow(final String serverURL) async { - final result = await LoginCheckServerStatusRoute(serverURL: serverURL).push(context); - if ((result ?? false) && mounted) { - // This needs be done, otherwise the context is dirty after returning from the previously pushed route - WidgetsBinding.instance.addPostFrameCallback((final _) async { - await LoginFlowRoute(serverURL: serverURL).push(context); - }); - } - } - @override Widget build(final BuildContext context) { final branding = Branding.of(context); @@ -61,7 +40,7 @@ class _LoginPageState extends State { return Scaffold( resizeToAvoidBottomInset: true, appBar: AppBar( - leading: Navigator.of(context).canPop() ? const CloseButton() : null, + leading: Navigator.canPop(context) ? const CloseButton() : null, ), body: Center( child: ConstrainedBox( @@ -102,7 +81,7 @@ class _LoginPageState extends State { Icons.qr_code_scanner, size: 50, ), - onPressed: () => unawaited(const LoginQrcodeRoute().push(context)), + onPressed: () => const LoginQrcodeRoute().go(context), ), const SizedBox( height: 20, @@ -121,11 +100,10 @@ class _LoginPageState extends State { hintText: 'https://...', ), keyboardType: TextInputType.url, - initialValue: widget.serverURL, validator: (final input) => validateHttpUrl(context, input), - onFieldSubmitted: (final input) async { + onFieldSubmitted: (final input) { if (_formKey.currentState!.validate()) { - await _beginLoginFlow(input); + LoginCheckServerStatusRoute(serverUrl: input).go(context); } else { _focusNode.requestFocus(); } diff --git a/packages/neon/neon/lib/src/pages/login_check_server_status.dart b/packages/neon/neon/lib/src/pages/login_check_server_status.dart index 1fd94472..48432fc0 100644 --- a/packages/neon/neon/lib/src/pages/login_check_server_status.dart +++ b/packages/neon/neon/lib/src/pages/login_check_server_status.dart @@ -3,6 +3,7 @@ import 'package:neon/l10n/localizations.dart'; import 'package:neon/src/bloc/result.dart'; import 'package:neon/src/bloc/result_builder.dart'; import 'package:neon/src/blocs/login_check_server_status.dart'; +import 'package:neon/src/router.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/widgets/exception.dart'; import 'package:neon/src/widgets/linear_progress_indicator.dart'; @@ -13,9 +14,19 @@ class LoginCheckServerStatusPage extends StatefulWidget { const LoginCheckServerStatusPage({ required this.serverURL, super.key, + }) : loginName = null, + password = null; + + const LoginCheckServerStatusPage.withCredentials({ + required this.serverURL, + required String this.loginName, + required String this.password, + super.key, }); final String serverURL; + final String? loginName; + final String? password; @override State createState() => _LoginCheckServerStatusPageState(); @@ -47,35 +58,49 @@ class _LoginCheckServerStatusPageState extends State constraints: NeonDialogTheme.of(context).constraints, child: ResultBuilder.behaviorSubject( stream: bloc.state, - builder: (final context, final state) => Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - NeonLinearProgressIndicator( - visible: state.isLoading, - ), - NeonException( - state.error, - onRetry: bloc.refresh, - ), - _buildServerVersionTile(state), - _buildMaintenanceModeTile(state), - Align( - alignment: Alignment.bottomRight, - child: ElevatedButton( - onPressed: state.hasData && state.requireData.isSupported && !state.requireData.maintenance - ? () => Navigator.of(context).pop(true) - : null, - child: Text(AppLocalizations.of(context).actionContinue), + builder: (final context, final state) { + final success = state.hasData && state.requireData.isSupported && !state.requireData.maintenance; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NeonLinearProgressIndicator( + visible: state.isLoading, + ), + NeonException( + state.error, + onRetry: bloc.refresh, ), - ), - ], - ), + _buildServerVersionTile(state), + _buildMaintenanceModeTile(state), + Align( + alignment: Alignment.bottomRight, + child: ElevatedButton( + onPressed: success ? _onContinue : null, + child: Text(AppLocalizations.of(context).actionContinue), + ), + ), + ], + ); + }, ), ), ), ), ); + void _onContinue() { + if (widget.loginName != null) { + LoginCheckAccountRoute( + serverUrl: widget.serverURL, + loginName: widget.loginName!, + password: widget.password!, + ).pushReplacement(context); + } else { + LoginFlowRoute(serverUrl: widget.serverURL).pushReplacement(context); + } + } + Widget _buildServerVersionTile(final Result result) { if (!result.hasData) { return NeonValidationTile( diff --git a/packages/neon/neon/lib/src/pages/login_flow.dart b/packages/neon/neon/lib/src/pages/login_flow.dart index c1cc8b52..7931e2a3 100644 --- a/packages/neon/neon/lib/src/pages/login_flow.dart +++ b/packages/neon/neon/lib/src/pages/login_flow.dart @@ -39,7 +39,7 @@ class _LoginFlowPageState extends State { bloc.result.listen((final result) { LoginCheckAccountRoute( - serverURL: result.server, + serverUrl: result.server, loginName: result.loginName, password: result.appPassword, ).pushReplacement(context); @@ -56,29 +56,32 @@ class _LoginFlowPageState extends State { Widget build(final BuildContext context) => Scaffold( appBar: AppBar(), body: Center( - child: ResultBuilder.behaviorSubject( - stream: bloc.init, - builder: (final context, final init) => Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - NeonLinearProgressIndicator( - visible: init.isLoading, - ), - NeonException( - init.error, - onRetry: bloc.refresh, - ), - if (init.hasData) ...[ - Text(AppLocalizations.of(context).loginSwitchToBrowserWindow), - const SizedBox( - height: 10, + child: Padding( + padding: const EdgeInsets.all(24), + child: ResultBuilder.behaviorSubject( + stream: bloc.init, + builder: (final context, final init) => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NeonLinearProgressIndicator( + visible: init.isLoading, ), - ElevatedButton( - onPressed: bloc.refresh, - child: Text(AppLocalizations.of(context).loginOpenAgain), + NeonException( + init.error, + onRetry: bloc.refresh, ), + if (init.hasData) ...[ + Text(AppLocalizations.of(context).loginSwitchToBrowserWindow), + const SizedBox( + height: 10, + ), + ElevatedButton( + onPressed: bloc.refresh, + child: Text(AppLocalizations.of(context).loginOpenAgain), + ), + ], ], - ], + ), ), ), ), diff --git a/packages/neon/neon/lib/src/pages/login_qrcode.dart b/packages/neon/neon/lib/src/pages/login_qrcode.dart index 08797c14..5c1086f2 100644 --- a/packages/neon/neon/lib/src/pages/login_qrcode.dart +++ b/packages/neon/neon/lib/src/pages/login_qrcode.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter_zxing/flutter_zxing.dart'; import 'package:neon/src/router.dart'; @@ -41,7 +39,12 @@ class _LoginQrcodePageState extends State { if (match == null) { throw InvalidQrcodeException(); } - await processLoginQrcode(context, match); + + LoginCheckServerStatusRoute.withCredentials( + serverUrl: match.server, + loginName: match.user, + password: match.password, + ).pushReplacement(context); } catch (e, s) { if (_lastErrorURL != url) { debugPrint(e.toString()); @@ -55,52 +58,3 @@ class _LoginQrcodePageState extends State { ), ); } - -class LoginQrcodeIntermediatePage extends StatefulWidget { - const LoginQrcodeIntermediatePage({ - required this.serverURL, - required this.loginName, - required this.password, - super.key, - }); - - final String serverURL; - final String loginName; - final String password; - - @override - State createState() => _LoginQrcodeIntermediatePageState(); -} - -class _LoginQrcodeIntermediatePageState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((final _) { - unawaited( - processLoginQrcode( - context, - LoginQrcode( - server: widget.serverURL, - user: widget.loginName, - password: widget.password, - ), - ), - ); - }); - } - - @override - Widget build(final BuildContext context) => const SizedBox(); -} - -Future processLoginQrcode(final BuildContext context, final LoginQrcode qrcode) async { - final result = await LoginCheckServerStatusRoute(serverURL: qrcode.server).push(context); - if ((result ?? false) && context.mounted) { - LoginCheckAccountRoute( - serverURL: qrcode.server, - loginName: qrcode.user, - password: qrcode.password, - ).pushReplacement(context); - } -} diff --git a/packages/neon/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index 80d0e06f..69ace82c 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -208,9 +208,7 @@ class _SettingsPageState extends State { ], CustomSettingsTile( title: ElevatedButton.icon( - onPressed: () { - const AddAccountRoute().go(context); - }, + onPressed: () async => const LoginRoute().push(context), icon: Icon(MdiIcons.accountPlus), label: Text(AppLocalizations.of(context).globalOptionsAccountsAdd), ), diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index 1a453365..708e5b46 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -1,3 +1,7 @@ +// ignore_for_file: unnecessary_overrides + +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; @@ -29,19 +33,17 @@ class AppRouter extends GoRouter { navigatorKey: navigatorKey, initialLocation: const HomeRoute().location, redirect: (final context, final state) { - final account = accountsBloc.activeAccount.valueOrNull; - final loginQrcode = LoginQrcode.tryParse(state.location); if (loginQrcode != null) { - return LoginQrcodeIntermediateRoute( - serverURL: loginQrcode.server, + return LoginCheckServerStatusRoute.withCredentials( + serverUrl: loginQrcode.server, loginName: loginQrcode.user, password: loginQrcode.password, ).location; } // redirect to loginscreen when no account is logged in - if (account == null && !state.location.startsWith(const LoginRoute().location)) { + if (!accountsBloc.hasAccounts && !state.location.startsWith(const LoginRoute().location)) { return const LoginRoute().location; } @@ -83,9 +85,23 @@ class AccountSettingsRoute extends GoRouteData { path: 'apps/:appid', name: 'NextcloudAppSettings', ), - TypedGoRoute( + TypedGoRoute<_AddAccountRoute>( path: 'account/add', name: 'addAccount', + routes: [ + TypedGoRoute<_AddAccountFlowRoute>( + path: 'flow', + ), + TypedGoRoute<_AddAccountQrcodeRoute>( + path: 'qrcode', + ), + TypedGoRoute<_AddAccountCheckServerStatusRoute>( + path: 'check/server', + ), + TypedGoRoute<_AddAccountCheckAccountRoute>( + path: 'check/account', + ), + ], ), TypedGoRoute( path: 'account/:accountid', @@ -113,47 +129,59 @@ class HomeRoute extends GoRouteData { name: 'login', routes: [ TypedGoRoute( - path: 'flow/:serverURL', - name: 'loginFlow', + path: 'flow', ), TypedGoRoute( path: 'qrcode', - name: 'loginQrcode', - ), - TypedGoRoute( - path: 'qrcode/intermediate/:serverURL/:loginName/:password', - name: 'loginQrcodeIntermediate', ), TypedGoRoute( - path: 'check/server/:serverURL', - name: 'checkServerStatus', + path: 'check/server', ), TypedGoRoute( - path: 'check/account/:serverURL/:loginName/:password', - name: 'checkAccount', + path: 'check/account', ), ], ) @immutable class LoginRoute extends GoRouteData { - const LoginRoute({this.serverURL}); + const LoginRoute(); - final String? serverURL; + @override + Widget build(final BuildContext context, final GoRouterState state) => const LoginPage(); @override - Widget build(final BuildContext context, final GoRouterState state) => LoginPage(serverURL: serverURL); + FutureOr redirect(final BuildContext context, final GoRouterState state) { + final hasAccounts = Provider.of(context, listen: false).hasAccounts; + + if (state.fullPath == location && hasAccounts) { + return const _AddAccountRoute().location; + } + + return null; + } } @immutable class LoginFlowRoute extends GoRouteData { const LoginFlowRoute({ - required this.serverURL, + required this.serverUrl, }); - final String serverURL; + final String serverUrl; @override - Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverURL); + Widget build(final BuildContext context, final GoRouterState state) => LoginFlowPage(serverURL: serverUrl); + + @override + FutureOr redirect(final BuildContext context, final GoRouterState state) { + final hasAccounts = Provider.of(context, listen: false).hasAccounts; + + if (state.fullPath == location && hasAccounts) { + return _AddAccountFlowRoute(serverUrl: serverUrl).location; + } + + return null; + } } @immutable @@ -162,68 +190,161 @@ class LoginQrcodeRoute extends GoRouteData { @override Widget build(final BuildContext context, final GoRouterState state) => const LoginQrcodePage(); -} -@immutable -class LoginQrcodeIntermediateRoute extends GoRouteData { - const LoginQrcodeIntermediateRoute({ - required this.serverURL, - required this.loginName, - required this.password, - }); + @override + FutureOr redirect(final BuildContext context, final GoRouterState state) { + final hasAccounts = Provider.of(context, listen: false).hasAccounts; - final String serverURL; - final String loginName; - final String password; + if (state.fullPath == location && hasAccounts) { + return const _AddAccountQrcodeRoute().location; + } - @override - Widget build(final BuildContext context, final GoRouterState state) => LoginQrcodeIntermediatePage( - serverURL: serverURL, - loginName: loginName, - password: password, - ); + return null; + } } @immutable class LoginCheckServerStatusRoute extends GoRouteData { const LoginCheckServerStatusRoute({ - required this.serverURL, - }); + required this.serverUrl, + }) : loginName = null, + password = null; - final String serverURL; + const LoginCheckServerStatusRoute.withCredentials({ + required this.serverUrl, + required String this.loginName, + required String this.password, + }) : assert(!kIsWeb, 'Might leak the password to the browser history'); + + final String serverUrl; + final String? loginName; + final String? password; @override - Widget build(final BuildContext context, final GoRouterState state) => LoginCheckServerStatusPage( - serverURL: serverURL, + Widget build(final BuildContext context, final GoRouterState state) { + if (loginName != null && password != null) { + return LoginCheckServerStatusPage.withCredentials( + serverURL: serverUrl, + loginName: loginName!, + password: password!, ); + } + + return LoginCheckServerStatusPage( + serverURL: serverUrl, + ); + } + + @override + FutureOr redirect(final BuildContext context, final GoRouterState state) { + final hasAccounts = Provider.of(context, listen: false).hasAccounts; + + if (state.fullPath == location && hasAccounts) { + if (loginName != null && password != null) { + return _AddAccountCheckServerStatusRoute.withCredentials( + serverUrl: serverUrl, + loginName: loginName!, + password: password!, + ).location; + } + + return _AddAccountCheckServerStatusRoute( + serverUrl: serverUrl, + ).location; + } + + return null; + } } @immutable class LoginCheckAccountRoute extends GoRouteData { const LoginCheckAccountRoute({ - required this.serverURL, + required this.serverUrl, required this.loginName, required this.password, - }); + }) : assert(!kIsWeb, 'Might leak the password to the browser history'); - final String serverURL; + final String serverUrl; final String loginName; final String password; @override Widget build(final BuildContext context, final GoRouterState state) => LoginCheckAccountPage( - serverURL: serverURL, + serverURL: serverUrl, loginName: loginName, password: password, ); + + @override + FutureOr redirect(final BuildContext context, final GoRouterState state) { + final hasAccounts = Provider.of(context, listen: false).hasAccounts; + + if (state.fullPath == location && hasAccounts) { + return _AddAccountCheckAccountRoute( + serverUrl: serverUrl, + loginName: loginName, + password: password, + ).location; + } + + return null; + } } @immutable -class AddAccountRoute extends GoRouteData { - const AddAccountRoute(); +class _AddAccountRoute extends LoginRoute { + const _AddAccountRoute(); +} +@immutable +class _AddAccountFlowRoute extends LoginFlowRoute { + const _AddAccountFlowRoute({ + required super.serverUrl, + }); @override - Widget build(final BuildContext context, final GoRouterState state) => const LoginPage(); + String get serverUrl => super.serverUrl; +} + +@immutable +class _AddAccountQrcodeRoute extends LoginQrcodeRoute { + const _AddAccountQrcodeRoute(); +} + +@immutable +class _AddAccountCheckServerStatusRoute extends LoginCheckServerStatusRoute { + const _AddAccountCheckServerStatusRoute({ + required super.serverUrl, + }); + + const _AddAccountCheckServerStatusRoute.withCredentials({ + required super.serverUrl, + required super.loginName, + required super.password, + }) : super.withCredentials(); + + @override + String get serverUrl => super.serverUrl; + @override + String? get loginName => super.loginName; + @override + String? get password => super.password; +} + +@immutable +class _AddAccountCheckAccountRoute extends LoginCheckAccountRoute { + const _AddAccountCheckAccountRoute({ + required super.serverUrl, + required super.loginName, + required super.password, + }); + + @override + String get serverUrl => super.serverUrl; + @override + String get loginName => super.loginName; + @override + String get password => super.password; } @immutable diff --git a/packages/neon/neon/lib/src/router.g.dart b/packages/neon/neon/lib/src/router.g.dart index c1730833..be315b41 100644 --- a/packages/neon/neon/lib/src/router.g.dart +++ b/packages/neon/neon/lib/src/router.g.dart @@ -29,7 +29,25 @@ RouteBase get $homeRoute => GoRouteData.$route( GoRouteData.$route( path: 'account/add', name: 'addAccount', - factory: $AddAccountRouteExtension._fromState, + factory: $_AddAccountRouteExtension._fromState, + routes: [ + GoRouteData.$route( + path: 'flow', + factory: $_AddAccountFlowRouteExtension._fromState, + ), + GoRouteData.$route( + path: 'qrcode', + factory: $_AddAccountQrcodeRouteExtension._fromState, + ), + GoRouteData.$route( + path: 'check/server', + factory: $_AddAccountCheckServerStatusRouteExtension._fromState, + ), + GoRouteData.$route( + path: 'check/account', + factory: $_AddAccountCheckAccountRouteExtension._fromState, + ), + ], ), GoRouteData.$route( path: 'account/:accountid', @@ -85,8 +103,8 @@ extension $NextcloudAppSettingsRouteExtension on NextcloudAppSettingsRoute { void pushReplacement(BuildContext context) => context.pushReplacement(location); } -extension $AddAccountRouteExtension on AddAccountRoute { - static AddAccountRoute _fromState(GoRouterState state) => const AddAccountRoute(); +extension $_AddAccountRouteExtension on _AddAccountRoute { + static _AddAccountRoute _fromState(GoRouterState state) => const _AddAccountRoute(); String get location => GoRouteData.$location( '/settings/account/add', @@ -99,6 +117,81 @@ extension $AddAccountRouteExtension on AddAccountRoute { void pushReplacement(BuildContext context) => context.pushReplacement(location); } +extension $_AddAccountFlowRouteExtension on _AddAccountFlowRoute { + static _AddAccountFlowRoute _fromState(GoRouterState state) => _AddAccountFlowRoute( + serverUrl: state.queryParameters['server-url']!, + ); + + String get location => GoRouteData.$location( + '/settings/account/add/flow', + queryParams: { + 'server-url': serverUrl, + }, + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + +extension $_AddAccountQrcodeRouteExtension on _AddAccountQrcodeRoute { + static _AddAccountQrcodeRoute _fromState(GoRouterState state) => const _AddAccountQrcodeRoute(); + + String get location => GoRouteData.$location( + '/settings/account/add/qrcode', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + +extension $_AddAccountCheckServerStatusRouteExtension on _AddAccountCheckServerStatusRoute { + static _AddAccountCheckServerStatusRoute _fromState(GoRouterState state) => _AddAccountCheckServerStatusRoute( + serverUrl: state.queryParameters['server-url']!, + ); + + String get location => GoRouteData.$location( + '/settings/account/add/check/server', + queryParams: { + 'server-url': serverUrl, + }, + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + +extension $_AddAccountCheckAccountRouteExtension on _AddAccountCheckAccountRoute { + static _AddAccountCheckAccountRoute _fromState(GoRouterState state) => _AddAccountCheckAccountRoute( + serverUrl: state.queryParameters['server-url']!, + loginName: state.queryParameters['login-name']!, + password: state.queryParameters['password']!, + ); + + String get location => GoRouteData.$location( + '/settings/account/add/check/account', + queryParams: { + 'server-url': serverUrl, + 'login-name': loginName, + 'password': password, + }, + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); +} + extension $AccountSettingsRouteExtension on AccountSettingsRoute { static AccountSettingsRoute _fromState(GoRouterState state) => AccountSettingsRoute( accountid: state.pathParameters['accountid']!, @@ -121,43 +214,29 @@ RouteBase get $loginRoute => GoRouteData.$route( factory: $LoginRouteExtension._fromState, routes: [ GoRouteData.$route( - path: 'flow/:serverURL', - name: 'loginFlow', + path: 'flow', factory: $LoginFlowRouteExtension._fromState, ), GoRouteData.$route( path: 'qrcode', - name: 'loginQrcode', factory: $LoginQrcodeRouteExtension._fromState, ), GoRouteData.$route( - path: 'qrcode/intermediate/:serverURL/:loginName/:password', - name: 'loginQrcodeIntermediate', - factory: $LoginQrcodeIntermediateRouteExtension._fromState, - ), - GoRouteData.$route( - path: 'check/server/:serverURL', - name: 'checkServerStatus', + path: 'check/server', factory: $LoginCheckServerStatusRouteExtension._fromState, ), GoRouteData.$route( - path: 'check/account/:serverURL/:loginName/:password', - name: 'checkAccount', + path: 'check/account', factory: $LoginCheckAccountRouteExtension._fromState, ), ], ); extension $LoginRouteExtension on LoginRoute { - static LoginRoute _fromState(GoRouterState state) => LoginRoute( - serverURL: state.queryParameters['server-u-r-l'], - ); + static LoginRoute _fromState(GoRouterState state) => const LoginRoute(); String get location => GoRouteData.$location( '/login', - queryParams: { - if (serverURL != null) 'server-u-r-l': serverURL, - }, ); void go(BuildContext context) => context.go(location); @@ -169,11 +248,14 @@ extension $LoginRouteExtension on LoginRoute { extension $LoginFlowRouteExtension on LoginFlowRoute { static LoginFlowRoute _fromState(GoRouterState state) => LoginFlowRoute( - serverURL: state.pathParameters['serverURL']!, + serverUrl: state.queryParameters['server-url']!, ); String get location => GoRouteData.$location( - '/login/flow/${Uri.encodeComponent(serverURL)}', + '/login/flow', + queryParams: { + 'server-url': serverUrl, + }, ); void go(BuildContext context) => context.go(location); @@ -197,31 +279,16 @@ extension $LoginQrcodeRouteExtension on LoginQrcodeRoute { void pushReplacement(BuildContext context) => context.pushReplacement(location); } -extension $LoginQrcodeIntermediateRouteExtension on LoginQrcodeIntermediateRoute { - static LoginQrcodeIntermediateRoute _fromState(GoRouterState state) => LoginQrcodeIntermediateRoute( - serverURL: state.pathParameters['serverURL']!, - loginName: state.pathParameters['loginName']!, - password: state.pathParameters['password']!, - ); - - String get location => GoRouteData.$location( - '/login/qrcode/intermediate/${Uri.encodeComponent(serverURL)}/${Uri.encodeComponent(loginName)}/${Uri.encodeComponent(password)}', - ); - - void go(BuildContext context) => context.go(location); - - Future push(BuildContext context) => context.push(location); - - void pushReplacement(BuildContext context) => context.pushReplacement(location); -} - extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { static LoginCheckServerStatusRoute _fromState(GoRouterState state) => LoginCheckServerStatusRoute( - serverURL: state.pathParameters['serverURL']!, + serverUrl: state.queryParameters['server-url']!, ); String get location => GoRouteData.$location( - '/login/check/server/${Uri.encodeComponent(serverURL)}', + '/login/check/server', + queryParams: { + 'server-url': serverUrl, + }, ); void go(BuildContext context) => context.go(location); @@ -233,13 +300,18 @@ extension $LoginCheckServerStatusRouteExtension on LoginCheckServerStatusRoute { extension $LoginCheckAccountRouteExtension on LoginCheckAccountRoute { static LoginCheckAccountRoute _fromState(GoRouterState state) => LoginCheckAccountRoute( - serverURL: state.pathParameters['serverURL']!, - loginName: state.pathParameters['loginName']!, - password: state.pathParameters['password']!, + serverUrl: state.queryParameters['server-url']!, + loginName: state.queryParameters['login-name']!, + password: state.queryParameters['password']!, ); String get location => GoRouteData.$location( - '/login/check/account/${Uri.encodeComponent(serverURL)}/${Uri.encodeComponent(loginName)}/${Uri.encodeComponent(password)}', + '/login/check/account', + queryParams: { + 'server-url': serverUrl, + 'login-name': loginName, + 'password': password, + }, ); void go(BuildContext context) => context.go(location); diff --git a/packages/neon/neon/lib/src/widgets/exception.dart b/packages/neon/neon/lib/src/widgets/exception.dart index aebf6816..db9f8db9 100644 --- a/packages/neon/neon/lib/src/widgets/exception.dart +++ b/packages/neon/neon/lib/src/widgets/exception.dart @@ -185,9 +185,11 @@ class NeonException extends StatelessWidget { } static void _openLoginPage(final BuildContext context) { - LoginRoute( - serverURL: Provider.of(context, listen: false).activeAccount.value!.serverURL, - ).go(context); + unawaited( + LoginCheckServerStatusRoute( + serverUrl: Provider.of(context, listen: false).activeAccount.value!.serverURL, + ).push(context), + ); } } From 7e1c083e688a35a63c029046d802cd1e237c2591 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Thu, 13 Jul 2023 17:49:58 +0200 Subject: [PATCH 4/4] neon: make LoginQrcode and Account a Credentials --- packages/neon/neon/lib/models.dart | 2 +- .../neon/neon/lib/src/models/account.dart | 123 +++++++++++++++++- .../neon/neon/lib/src/pages/login_qrcode.dart | 6 +- packages/neon/neon/lib/src/router.dart | 5 +- .../neon/neon/lib/src/utils/login_qrcode.dart | 47 ------- packages/neon/neon/test/account_test.dart | 25 ++++ 6 files changed, 152 insertions(+), 56 deletions(-) delete mode 100644 packages/neon/neon/lib/src/utils/login_qrcode.dart create mode 100644 packages/neon/neon/test/account_test.dart diff --git a/packages/neon/neon/lib/models.dart b/packages/neon/neon/lib/models.dart index 3c441f6e..ce1f317b 100644 --- a/packages/neon/neon/lib/models.dart +++ b/packages/neon/neon/lib/models.dart @@ -1,3 +1,3 @@ -export 'package:neon/src/models/account.dart'; +export 'package:neon/src/models/account.dart' hide Credentials, LoginQrcode; export 'package:neon/src/models/app_implementation.dart'; export 'package:neon/src/models/notifications_interface.dart'; diff --git a/packages/neon/neon/lib/src/models/account.dart b/packages/neon/neon/lib/src/models/account.dart index 5dbaca84..93ce1391 100644 --- a/packages/neon/neon/lib/src/models/account.dart +++ b/packages/neon/neon/lib/src/models/account.dart @@ -2,15 +2,29 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; -import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:meta/meta.dart'; import 'package:nextcloud/nextcloud.dart'; part 'account.g.dart'; +/// Credentials interface +@internal +@immutable +abstract interface class Credentials { + /// Url of the server + abstract final String serverURL; + + /// Username + abstract final String username; + + /// Password + abstract final String? password; +} + @JsonSerializable() @immutable -class Account { +class Account implements Credentials { Account({ required this.serverURL, required this.loginName, @@ -29,9 +43,12 @@ class Account { factory Account.fromJson(final Map json) => _$AccountFromJson(json); Map toJson() => _$AccountToJson(this); + @override final String serverURL; final String loginName; + @override final String username; + @override final String? password; final String? userAgent; @@ -76,3 +93,105 @@ extension AccountFind on Iterable { Account? tryFind(final String? accountID) => firstWhereOrNull((final account) => account.id == accountID); Account find(final String accountID) => firstWhere((final account) => account.id == accountID); } + +/// Qrcode Login credentials. +/// +/// The Credentials as provided by the server when manually creating an app +/// password. +@internal +@immutable +class LoginQrcode implements Credentials { + /// Creates a new LoginQrcode object. + @visibleForTesting + const LoginQrcode({ + required this.serverURL, + required this.username, + required this.password, + }); + + @override + final String serverURL; + @override + final String username; + @override + final String password; + + /// Pattern matching the full Qrcode content. + static final _loginQrcodeUrlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$'); + + /// Pattern matching the path part of the Qrcode. + /// + /// This is used when launching the app through an intent. + static final _loginQrcodePathRegex = RegExp(r'^/user:(.*)&password:(.*)&server:(.*)$'); + + /// Creates a new `LoginQrcode` object by parsing a url string. + /// + /// If the [url] string is not valid as a LoginQrcode a [FormatException] is + /// thrown. + /// + /// Example: + /// ```dart + /// final loginQrcode = + /// LoginQrcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com'); + /// print(loginQrcode.serverURL); // JohnDoe + /// print(loginQrcode.username); // super_secret + /// print(loginQrcode.password); // example.com + /// + /// LoginQrcode.parse('::Not valid LoginQrcode::'); // Throws FormatException. + /// ``` + static LoginQrcode parse(final String url) { + for (final regex in [_loginQrcodeUrlRegex, _loginQrcodePathRegex]) { + final matches = regex.allMatches(url); + if (matches.isEmpty) { + continue; + } + + final match = matches.single; + if (match.groupCount != 3) { + continue; + } + + return LoginQrcode( + serverURL: match.group(3)!, + username: match.group(1)!, + password: match.group(2)!, + ); + } + + throw const FormatException(); + } + + /// Creates a new `LoginQrcode` object by parsing a url string. + /// + /// Returns `null` if the [url] string is not valid as a LoginQrcode. + /// + /// Example: + /// ```dart + /// final loginQrcode = + /// LoginQrcode.parse('nc://login/user:JohnDoe&password:super_secret&server:example.com'); + /// print(loginQrcode.serverURL); // JohnDoe + /// print(loginQrcode.username); // super_secret + /// print(loginQrcode.password); // example.com + /// + /// final notLoginQrcode = LoginQrcode.tryParse('::Not valid LoginQrcode::'); + /// print(notLoginQrcode); // null + /// ``` + static LoginQrcode? tryParse(final String url) { + try { + return parse(url); + } on FormatException { + return null; + } + } + + @override + bool operator ==(final Object other) => + other is LoginQrcode && other.serverURL == serverURL && other.username == username && other.password == password; + + @override + int get hashCode => Object.hashAll([ + serverURL, + username, + password, + ]); +} diff --git a/packages/neon/neon/lib/src/pages/login_qrcode.dart b/packages/neon/neon/lib/src/pages/login_qrcode.dart index 5c1086f2..c92edb0b 100644 --- a/packages/neon/neon/lib/src/pages/login_qrcode.dart +++ b/packages/neon/neon/lib/src/pages/login_qrcode.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_zxing/flutter_zxing.dart'; +import 'package:neon/src/models/account.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/exceptions.dart'; -import 'package:neon/src/utils/login_qrcode.dart'; import 'package:neon/src/widgets/exception.dart'; class LoginQrcodePage extends StatefulWidget { @@ -41,8 +41,8 @@ class _LoginQrcodePageState extends State { } LoginCheckServerStatusRoute.withCredentials( - serverUrl: match.server, - loginName: match.user, + serverUrl: match.serverURL, + loginName: match.username, password: match.password, ).pushReplacement(context); } catch (e, s) { diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index 708e5b46..72b6ca43 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -17,7 +17,6 @@ import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_qrcode.dart'; import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/settings.dart'; -import 'package:neon/src/utils/login_qrcode.dart'; import 'package:neon/src/utils/stream_listenable.dart'; import 'package:provider/provider.dart'; @@ -36,8 +35,8 @@ class AppRouter extends GoRouter { final loginQrcode = LoginQrcode.tryParse(state.location); if (loginQrcode != null) { return LoginCheckServerStatusRoute.withCredentials( - serverUrl: loginQrcode.server, - loginName: loginQrcode.user, + serverUrl: loginQrcode.serverURL, + loginName: loginQrcode.username, password: loginQrcode.password, ).location; } diff --git a/packages/neon/neon/lib/src/utils/login_qrcode.dart b/packages/neon/neon/lib/src/utils/login_qrcode.dart deleted file mode 100644 index 4e2081e2..00000000 --- a/packages/neon/neon/lib/src/utils/login_qrcode.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:meta/meta.dart'; - -@internal -class LoginQrcode { - LoginQrcode({ - required this.server, - required this.user, - required this.password, - }); - - static final _loginQrcodeUrlRegex = RegExp(r'^nc://login/user:(.*)&password:(.*)&server:(.*)$'); - static final _loginQrcodePathRegex = RegExp(r'^/user:(.*)&password:(.*)&server:(.*)$'); - - static LoginQrcode parse(final String url) { - for (final regex in [_loginQrcodeUrlRegex, _loginQrcodePathRegex]) { - final matches = regex.allMatches(url); - if (matches.isEmpty) { - continue; - } - - final match = matches.single; - if (match.groupCount != 3) { - continue; - } - - return LoginQrcode( - server: match.group(3)!, - user: match.group(1)!, - password: match.group(2)!, - ); - } - - throw const FormatException(); - } - - static LoginQrcode? tryParse(final String url) { - try { - return parse(url); - } on FormatException { - return null; - } - } - - final String server; - final String user; - final String password; -} diff --git a/packages/neon/neon/test/account_test.dart b/packages/neon/neon/test/account_test.dart new file mode 100644 index 00000000..57b7c527 --- /dev/null +++ b/packages/neon/neon/test/account_test.dart @@ -0,0 +1,25 @@ +import 'package:neon/src/models/account.dart'; +import 'package:test/test.dart'; + +void main() { + const qrCodePath = '/user:JohnDoe&password:super_secret&server:example.com'; + const qrCode = 'nc://login$qrCodePath'; + const invalidUrl = '::Not valid LoginQrcode::'; + const credentials = LoginQrcode( + serverURL: 'example.com', + username: 'JohnDoe', + password: 'super_secret', + ); + + group('LoginQrcode', () { + test('parse', () { + expect(LoginQrcode.tryParse(qrCode), equals(credentials)); + expect(LoginQrcode.tryParse(qrCodePath), equals(credentials)); + expect(LoginQrcode.tryParse(invalidUrl), null); + }); + + test('equality', () { + expect(credentials, equals(credentials)); + }); + }); +}