diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d362766..cf931f7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -40,6 +40,8 @@ PODS: - Flutter - image_picker_ios (0.0.1): - Flutter + - integration_test (0.0.1): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -60,6 +62,7 @@ DEPENDENCIES: - flutter_downloader (from `.symlinks/plugins/flutter_downloader/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) @@ -82,6 +85,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: @@ -97,6 +102,7 @@ SPEC CHECKSUMS: flutter_downloader: b7301ae057deadd4b1650dc7c05375f10ff12c39 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + integration_test: 13825b8a9334a850581300559b8839134b124670 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 diff --git a/lib/common/settings.dart b/lib/common/settings.dart index 9ac0be0..a959104 100644 --- a/lib/common/settings.dart +++ b/lib/common/settings.dart @@ -4,50 +4,70 @@ import 'package:flutter/foundation.dart'; import 'package:nocodb/common/preferences.dart'; -final settings = Settings(); +import 'package:nocodb/nocodb_sdk/client.dart'; + +final settings = _Settings(); class Settings { + Settings({required this.host, required this.token, this.username}); + + final String? username; + final String host; + final Token token; +} + +const _kUsername = 'username'; +const _kHost = 'host'; +const _kAuthToken = 'auth_token'; +const _kApiToken = 'api_token'; + +class _Settings { Preferences? prefs; bool get initialized => prefs != null; init(Preferences prefs) { this.prefs = prefs; } - static const _authToken = 'authToken'; - Future get authToken async => - await prefs?.getSecure(key: _authToken); + Future save({ + required String host, + required Token token, + String? username, + }) async { + await clear(); + await prefs?.set(key: _kHost, value: host); + if (username != null) { + await prefs?.set(key: _kUsername, value: username); + } - Future setAuthToken(String v) async => - await prefs?.set(key: _authToken, value: v, secure: true); + switch (token) { + case AuthToken(authToken: final authToken): + await prefs?.set(key: _kAuthToken, value: authToken, secure: true); + case ApiToken(apiToken: final apiToken): + await prefs?.set(key: _kApiToken, value: apiToken, secure: true); + default: + throw Exception('unsupported token type: ${token.runtimeType}'); + } + } - static const _email = 'email'; - Future get email async => await prefs?.get(key: _email); + Future get() async { + final host = await prefs?.get(key: _kHost); + if (host == null) { + return null; + } - Future setEmail(String v) async => await prefs?.set( - key: _email, - value: v, - ); + final username = await prefs?.get(key: _kUsername); + final authToken = await prefs?.getSecure(key: _kAuthToken); + if (authToken != null) { + return Settings(username: username, host: host, token: AuthToken(authToken)); + } - static const _apiBaseUrl = 'apiBaseUrl'; - Future get apiBaseUrl async { - final v = await prefs?.get(key: _apiBaseUrl); - if (v == null && !kIsWeb && Platform.isAndroid) { - return 'http://10.0.2.2:8080'; + final apiToken = await prefs?.getSecure(key: _kApiToken); + if (apiToken != null) { + return Settings(username: username, host: host, token: ApiToken(apiToken)); } - return v; + return null; } - Future setApiBaseUrl(String v) async => - await prefs?.set(key: _apiBaseUrl, value: v); - - static const _rememberMe = 'rememberMe'; - Future get rememberMe async => - await prefs?.get(key: _rememberMe) ?? false; - Future setRememberMe(bool v) async => await prefs?.set( - key: _rememberMe, - value: v, - ); - Future clear() async { await prefs?.clear(); } diff --git a/lib/features/sign_in/pages/sign_in.dart b/lib/features/sign_in/pages/sign_in.dart index c021276..c72ee0c 100644 --- a/lib/features/sign_in/pages/sign_in.dart +++ b/lib/features/sign_in/pages/sign_in.dart @@ -1,4 +1,5 @@ -import 'package:easy_debounce/easy_debounce.dart'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -10,169 +11,162 @@ import 'package:nocodb/routes.dart'; class SignInPage extends HookConsumerWidget { const SignInPage({super.key}); - Widget? _getConnectivityIcon(bool? connectivity) { - if (connectivity == null) { - return null; - } else if (connectivity) { - return const Padding( - padding: EdgeInsets.only(top: 16), - child: Icon( - Icons.check_circle, - size: 20, - // color: Colors.greenAccent, - ), - ); - } else { - return const Padding( - padding: EdgeInsets.only(top: 16), - child: Icon( - Icons.warning, - size: 20, - ), - ); - } - } - - Widget _buildDialog(BuildContext context, WidgetRef ref) { - final emailController = useTextEditingController(); + _build1(BuildContext context, WidgetRef ref) { + final hostController = useTextEditingController(); + final usernameController = useTextEditingController(); final passwordController = useTextEditingController(); - final apiUrlController = useTextEditingController(); + final apiTokenController = useTextEditingController(); final showPassword = useState(false); - final connectivity = useState(null); - final rememberMe = useState(false); + final showApiToken = useState(false); + + final rememberMe = useState(true); + + // Disable username and password field when API token is entered. + final useApiToken = useState(false); useEffect( () { - // ignore: discarded_futures + // ignore_for_file: discarded_futures () async { - emailController.text = await settings.email ?? ''; - apiUrlController.text = await settings.apiBaseUrl ?? ''; - rememberMe.value = await settings.rememberMe; + final s = await settings.get(); + if (s == null) { + return; + } + rememberMe.value = true; + hostController.text = s.host; + if (!kIsWeb && Platform.isAndroid) { + hostController.text = 'http://10.0.2.2:8080'; + } + + if (s.username != null) { + usernameController.text = s.username!; + } }(); return null; }, [], ); - return AlertDialog( - title: const Text('SIGN IN'), - content: IntrinsicHeight( - child: AutofillGroup( - child: Column( - children: [ - TextField( - key: const ValueKey('email'), - autofillHints: const [ - AutofillHints.email, - AutofillHints.username, - ], - controller: emailController, - decoration: const InputDecoration( - labelText: 'Email', - ), + return Container( + padding: const EdgeInsets.all(16), + child: AutofillGroup( + child: Column( + children: [ + TextField( + autofillHints: const [ + AutofillHints.url, + ], + controller: hostController, + decoration: const InputDecoration( + labelText: 'Host', ), - TextField( - key: const ValueKey('password'), - autofillHints: const [ - AutofillHints.password, - ], - controller: passwordController, - decoration: InputDecoration( - labelText: 'Password', - suffixIcon: IconButton( - icon: Icon( - showPassword.value - ? Icons.visibility - : Icons.visibility_off, - ), - onPressed: () { - showPassword.value = !showPassword.value; - }, + ), + Container(height: 16), + TextField( + enabled: !useApiToken.value, + autofillHints: const [ + AutofillHints.email, + AutofillHints.username, + ], + controller: usernameController, + decoration: const InputDecoration( + labelText: 'Username', + ), + ), + TextField( + enabled: !useApiToken.value, + autofillHints: const [ + AutofillHints.password, + ], + controller: passwordController, + decoration: InputDecoration( + labelText: 'Password', + suffixIcon: IconButton( + icon: Icon( + showPassword.value + ? Icons.visibility + : Icons.visibility_off, ), + onPressed: () { + showPassword.value = !showPassword.value; + }, ), - obscureText: !showPassword.value, ), - TextField( - key: const ValueKey('endpoint'), - onChanged: (value) { - EasyDebounce.debounce( - 'api_endpoint', - const Duration(seconds: 1), - () async { - await api.version(apiUrlController.text).then((result) { - connectivity.value = true; - }).onError( - (error, stackTrace) { - notifyError(context, error, stackTrace); - connectivity.value = false; - }, - ); - }, - ); - }, - autofillHints: const [ - AutofillHints.url, - ], - controller: apiUrlController, - decoration: InputDecoration( - labelText: 'API Endpoint', - suffixIcon: _getConnectivityIcon(connectivity.value), + obscureText: !showPassword.value, + ), + Container(height: 16), + const Text('OR'), + TextField( + controller: apiTokenController, + decoration: InputDecoration( + labelText: 'API token', + suffixIcon: IconButton( + icon: Icon( + showApiToken.value + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + showApiToken.value = !showApiToken.value; + }, ), ), - const SizedBox(height: 8), - Row( - children: [ - Checkbox( - value: rememberMe.value, - onChanged: (_) { - rememberMe.value = !rememberMe.value; + obscureText: !showApiToken.value, + onChanged: (value) { + if (useApiToken.value != value.isNotEmpty) { + useApiToken.value = value.isNotEmpty; + } + }, + ), + Container(height: 16), + Row( + children: [ + Checkbox( + value: rememberMe.value, + onChanged: (value) { + rememberMe.value = value!; + }, + ), + const Text('Remember Me'), + ], + ), + ElevatedButton( + onPressed: () async { + Token? token; + if (useApiToken.value) { + token = ApiToken(apiTokenController.text); + } else { + api.init(hostController.text); + (await api.authSignin( + usernameController.text, + passwordController.text, + )) + .when( + ok: (value) { + token = AuthToken(value); }, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - const Text('Remember Me'), - ], - ), - ], - ), + ng: (Object error, StackTrace? stackTrace) { + notifyError(context, error, stackTrace); + }, + ); + } + api.init(hostController.text, token: token); + + if (rememberMe.value && token != null) { + await settings.save(host: hostController.text, token: token!); + } + + if (!context.mounted) { + return; + } + const ProjectListRoute().go(context); + }, + child: const Text('Sign In'), + ), + ], ), ), - actions: [ - TextButton( - key: const ValueKey('sign_in_button'), - child: const Text('SIGN IN'), - onPressed: () async { - api.init(apiUrlController.text); - await api - .authSignin( - emailController.text, - passwordController.text, - ) - .then((authToken) async { - await settings.setApiBaseUrl(apiUrlController.text); - await authToken.when( - ok: (token) async { - if (rememberMe.value) { - await settings.setEmail(emailController.text); - await settings.setRememberMe(rememberMe.value); - await settings.setAuthToken(token); - } else { - await settings.clear(); - } - if (context.mounted) { - const ProjectListRoute().go(context); - } - }, - ng: (error, stackTrace) { - notifyError(context, error, stackTrace); - }, - ); - }).onError( - (error, stackTrace) => notifyError(context, error, stackTrace), - ); - }, - ), - ], ); } @@ -181,9 +175,6 @@ class SignInPage extends HookConsumerWidget { appBar: AppBar( title: const Text('NocoDB'), ), - body: Center( - // TODO: Stop using dialog? - child: _buildDialog(context, ref), - ), + body: _build1(context, ref), ); } diff --git a/lib/nocodb_sdk/client.dart b/lib/nocodb_sdk/client.dart index 4d5e455..314a996 100644 --- a/lib/nocodb_sdk/client.dart +++ b/lib/nocodb_sdk/client.dart @@ -13,6 +13,18 @@ import 'package:nocodb/nocodb_sdk/symbols.dart'; const _defaultOrg = 'noco'; +sealed class Token {} + +class AuthToken extends Token { + AuthToken(this.authToken); + final String authToken; +} + +class ApiToken extends Token { + ApiToken(this.apiToken); + final String apiToken; +} + sealed class NcFile {} class NcPlatformFile extends NcFile { @@ -81,12 +93,20 @@ class _Api { late Uri _baseUri; Uri get uri => _baseUri; - init(String url, {String? authToken}) { + init(String url, {Token? token}) { _baseUri = Uri.parse(url); - if (authToken != null) { - _client.addHeaders({'xc-auth': authToken}); - } else { - _client.removeHeader('xc-auth'); + + if (token == null) { + return; + } + switch (token) { + case AuthToken(authToken: final authToken): + _client.addHeaders({'xc-auth': authToken}); + case ApiToken(apiToken: final apiToken): + _client.addHeaders({'xc-token': apiToken}); + default: + _client.removeHeader('xc-auth'); + _client.removeHeader('xc-token'); } } diff --git a/lib/router.dart b/lib/router.dart index cdfb653..5aa3162 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -62,41 +62,23 @@ FutureOr redirect( logger.fine('loaded settings from storage.'); } - final rememberMe = await settings.rememberMe; - if (!rememberMe) { - return null; + final s = await settings.get(); + if (s == null) { + await settings.clear(); + return const HomeRoute().location; } + final Settings(:host, :token) = s; - final apiBaseUrl = await settings.apiBaseUrl; - final authToken = await settings.authToken; - - logger.config('apiBaseUrl: $apiBaseUrl'); - if (authToken == null || apiBaseUrl == null) { - return const HomeRoute().location; - } else if (state.path == null || state.path == const HomeRoute().location) { - if (!rememberMe) { - await settings.clear(); + logger + ..config('host: $host') + ..config('state.path: ${state.uri.toString()}'); + if (state.uri.toString() == const HomeRoute().location) { + if (token is AuthToken && !isAuthTokenAlive(token.authToken)) { + logger.info('authToken is expired.'); return const HomeRoute().location; } - - final isAlive = isAuthTokenAlive(authToken); - logger.info( - 'authToken: ${isAlive ? 'alive' : 'expired'}', - ); - - if (isAlive) { - api.init(apiBaseUrl, authToken: authToken); - return const ProjectListRoute().location; - // final result = await api.authUserMe(); - // return result.when( - // ok: (final ok) { - // api.init(apiBaseUrl, authToken: authToken); - // return const ProjectListRoute().location; - // }, - // ng: (final error, final stackTrace) => - // notifyError(context, error, stackTrace), - // ); - } + api.init(host, token: token); + return const ProjectListRoute().location; } } catch (e, s) { logger