diff --git a/mobile/lib/models/auth/oauth_login_data.model.dart b/mobile/lib/models/auth/oauth_login_data.model.dart new file mode 100644 index 0000000000..ec0744f28f --- /dev/null +++ b/mobile/lib/models/auth/oauth_login_data.model.dart @@ -0,0 +1,7 @@ +class OAuthLoginData { + final String serverUrl; + final String state; + final String codeVerifier; + + const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier}); +} diff --git a/mobile/lib/pages/login/login.page.dart b/mobile/lib/pages/login/login.page.dart index e1d551900f..07c7cd7dab 100644 --- a/mobile/lib/pages/login/login.page.dart +++ b/mobile/lib/pages/login/login.page.dart @@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget { }); return Scaffold( - body: LoginForm(), + body: const LoginForm(), bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.only(bottom: 16.0), diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 9a15598998..8d5d4f2971 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -12,13 +12,29 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:immich_mobile/services/server_info.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; import 'package:immich_mobile/services/widget.service.dart'; import 'package:immich_mobile/utils/hash.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:immich_mobile/utils/debug_print.dart'; +class ServerAuthSettings { + final String endpoint; + final bool isOAuthEnabled; + final bool isPasswordLoginEnabled; + final String oAuthButtonText; + + const ServerAuthSettings({ + required this.endpoint, + required this.isOAuthEnabled, + required this.isPasswordLoginEnabled, + required this.oAuthButtonText, + }); +} + final authProvider = StateNotifierProvider((ref) { return AuthNotifier( ref.watch(authServiceProvider), @@ -27,6 +43,7 @@ final authProvider = StateNotifierProvider((ref) { ref.watch(uploadServiceProvider), ref.watch(secureStorageServiceProvider), ref.watch(widgetServiceProvider), + ref.watch(serverInfoServiceProvider), ); }); @@ -37,6 +54,7 @@ class AuthNotifier extends StateNotifier { final UploadService _uploadService; final SecureStorageService _secureStorageService; final WidgetService _widgetService; + final ServerInfoService _serverInfoService; final _log = Logger("AuthenticationNotifier"); static const Duration _timeoutDuration = Duration(seconds: 7); @@ -48,6 +66,7 @@ class AuthNotifier extends StateNotifier { this._uploadService, this._secureStorageService, this._widgetService, + this._serverInfoService, ) : super( const AuthState( deviceId: "", @@ -64,6 +83,27 @@ class AuthNotifier extends StateNotifier { return _authService.validateServerUrl(url); } + Future getServerAuthSettings(String serverUrl) async { + final sanitizedUrl = sanitizeUrl(serverUrl); + final encodedUrl = punycodeEncodeUrl(sanitizedUrl); + + final endpoint = await _authService.validateServerUrl(encodedUrl); + + final features = await _serverInfoService.getServerFeatures(); + final config = await _serverInfoService.getServerConfig(); + + if (features == null || config == null) { + return null; + } + + return ServerAuthSettings( + endpoint: endpoint, + isOAuthEnabled: features.oauthEnabled, + isPasswordLoginEnabled: features.passwordLogin, + oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth', + ); + } + /// Validating the url is the alternative connecting server url without /// saving the information to the local database Future validateAuxilaryServerUrl(String url) async { diff --git a/mobile/lib/providers/oauth.provider.dart b/mobile/lib/providers/oauth.provider.dart index 14b3353943..e6c3e0c193 100644 --- a/mobile/lib/providers/oauth.provider.dart +++ b/mobile/lib/providers/oauth.provider.dart @@ -1,5 +1,27 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/services/oauth.service.dart'; +import 'package:immich_mobile/models/auth/oauth_login_data.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/services/oauth.service.dart'; +import 'package:openapi/api.dart'; + +export 'package:immich_mobile/models/auth/oauth_login_data.model.dart'; final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider))); + +final oAuthProvider = StateNotifierProvider>( + (ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)), +); + +class OAuthNotifier extends StateNotifier> { + final OAuthService _oAuthService; + + OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null)); + + Future getOAuthLoginData(String serverUrl) { + return _oAuthService.getOAuthLoginData(serverUrl); + } + + Future completeOAuthLogin(OAuthLoginData oAuthData) { + return _oAuthService.completeOAuthLogin(oAuthData); + } +} diff --git a/mobile/lib/services/oauth.service.dart b/mobile/lib/services/oauth.service.dart index 99ceca3229..dabe9a2822 100644 --- a/mobile/lib/services/oauth.service.dart +++ b/mobile/lib/services/oauth.service.dart @@ -1,5 +1,11 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:immich_mobile/models/auth/oauth_login_data.model.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; @@ -11,6 +17,50 @@ class OAuthService { final log = Logger('OAuthService'); OAuthService(this._apiService); + String _generateRandomString(int length) { + const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; + final random = Random.secure(); + return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length)))); + } + + List _randomBytes(int length) { + final random = Random.secure(); + return List.generate(length, (i) => random.nextInt(256)); + } + + /// Per specification, the code verifier must be 43-128 characters long + /// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"] + /// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + String _randomCodeVerifier() { + return base64Url.encode(_randomBytes(42)); + } + + String _generatePKCECodeChallenge(String codeVerifier) { + final bytes = utf8.encode(codeVerifier); + final digest = sha256.convert(bytes); + return base64Url.encode(digest.bytes).replaceAll('=', ''); + } + + /// Initiates OAuth login flow. + /// Returns the OAuth server URL to redirect to, along with PKCE parameters. + Future getOAuthLoginData(String serverUrl) async { + final state = _generateRandomString(32); + final codeVerifier = _randomCodeVerifier(); + final codeChallenge = _generatePKCECodeChallenge(codeVerifier); + + final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge); + + if (oAuthServerUrl == null) { + return null; + } + + return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier); + } + + Future completeOAuthLogin(OAuthLoginData oAuthData) { + return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier); + } + Future getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async { // Resolve API server endpoint from user provided serverUrl await _apiService.resolveAndSetEndpoint(serverUrl); diff --git a/mobile/lib/widgets/forms/login/login_button.dart b/mobile/lib/widgets/forms/login/login_button.dart index 0f9fb21d8f..1c5393ee95 100644 --- a/mobile/lib/widgets/forms/login/login_button.dart +++ b/mobile/lib/widgets/forms/login/login_button.dart @@ -1,14 +1,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -class LoginButton extends ConsumerWidget { - final Function() onPressed; +class LoginButton extends StatelessWidget { + final VoidCallback onPressed; const LoginButton({super.key, required this.onPressed}); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ElevatedButton.icon( style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)), onPressed: onPressed, diff --git a/mobile/lib/widgets/forms/login/login_credentials_form.dart b/mobile/lib/widgets/forms/login/login_credentials_form.dart new file mode 100644 index 0000000000..df6ff91983 --- /dev/null +++ b/mobile/lib/widgets/forms/login/login_credentials_form.dart @@ -0,0 +1,95 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/utils/url_helper.dart'; +import 'package:immich_mobile/widgets/forms/login/email_input.dart'; +import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; +import 'package:immich_mobile/widgets/forms/login/login_button.dart'; +import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart'; +import 'package:immich_mobile/widgets/forms/login/password_input.dart'; +import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart'; + +class LoginCredentialsForm extends StatelessWidget { + final TextEditingController emailController; + final TextEditingController passwordController; + final TextEditingController serverEndpointController; + final FocusNode emailFocusNode; + final FocusNode passwordFocusNode; + final bool isLoading; + final bool isOAuthEnabled; + final bool isPasswordLoginEnabled; + final String oAuthButtonLabel; + final String? warningMessage; + final VoidCallback onLogin; + final VoidCallback onOAuthLogin; + final VoidCallback onBack; + + const LoginCredentialsForm({ + super.key, + required this.emailController, + required this.passwordController, + required this.serverEndpointController, + required this.emailFocusNode, + required this.passwordFocusNode, + required this.isLoading, + required this.isOAuthEnabled, + required this.isPasswordLoginEnabled, + required this.oAuthButtonLabel, + required this.warningMessage, + required this.onLogin, + required this.onOAuthLogin, + required this.onBack, + }); + + @override + Widget build(BuildContext context) { + return AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!), + Text( + sanitizeUrl(serverEndpointController.text), + style: context.textTheme.displaySmall, + textAlign: TextAlign.center, + ), + if (isPasswordLoginEnabled) ...[ + const SizedBox(height: 18), + EmailInput( + controller: emailController, + focusNode: emailFocusNode, + onSubmit: passwordFocusNode.requestFocus, + ), + const SizedBox(height: 8), + PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin), + ], + isLoading + ? const LoadingIcon() + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 18), + if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin), + if (isOAuthEnabled) ...[ + if (isPasswordLoginEnabled) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black), + ), + OAuthLoginButton( + serverEndpointController: serverEndpointController, + buttonLabel: oAuthButtonLabel, + onPressed: onOAuthLogin, + ), + ], + ], + ), + if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()), + const SizedBox(height: 12), + TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index f810973298..f8ba7e1eed 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -1,14 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'package:auto_route/auto_route.dart'; -import 'package:crypto/crypto.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -29,492 +25,382 @@ import 'package:immich_mobile/utils/version_compatibility.dart'; import 'package:immich_mobile/widgets/common/immich_logo.dart'; import 'package:immich_mobile/widgets/common/immich_title_text.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/forms/login/email_input.dart'; -import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; -import 'package:immich_mobile/widgets/forms/login/login_button.dart'; -import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart'; -import 'package:immich_mobile/widgets/forms/login/password_input.dart'; -import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart'; +import 'package:immich_mobile/widgets/forms/login/login_credentials_form.dart'; +import 'package:immich_mobile/widgets/forms/login/server_selection_form.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; -class LoginForm extends HookConsumerWidget { - LoginForm({super.key}); - - final log = Logger('LoginForm'); +class LoginForm extends ConsumerStatefulWidget { + const LoginForm({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final emailController = useTextEditingController.fromValue(TextEditingValue.empty); - final passwordController = useTextEditingController.fromValue(TextEditingValue.empty); - final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); - final emailFocusNode = useFocusNode(); - final passwordFocusNode = useFocusNode(); - final serverEndpointFocusNode = useFocusNode(); - final isLoading = useState(false); - final isLoadingServer = useState(false); - final isOauthEnable = useState(false); - final isPasswordLoginEnable = useState(false); - final oAuthButtonLabel = useState('OAuth'); - final logoAnimationController = useAnimationController(duration: const Duration(seconds: 60))..repeat(); - final serverInfo = ref.watch(serverInfoProvider); - final warningMessage = useState(null); - final loginFormKey = GlobalKey(); - final ValueNotifier serverEndpoint = useState(null); + ConsumerState createState() => _LoginFormState(); +} - checkVersionMismatch() async { - try { - final packageInfo = await PackageInfo.fromPlatform(); - final appVersion = packageInfo.version; - final appMajorVersion = int.parse(appVersion.split('.')[0]); - final appMinorVersion = int.parse(appVersion.split('.')[1]); - final serverMajorVersion = serverInfo.serverVersion.major; - final serverMinorVersion = serverInfo.serverVersion.minor; +class _LoginFormState extends ConsumerState with SingleTickerProviderStateMixin { + final _log = Logger('LoginForm'); + final _loginFormKey = GlobalKey(); - warningMessage.value = getVersionCompatibilityMessage( + late final TextEditingController _emailController; + late final TextEditingController _passwordController; + late final TextEditingController _serverEndpointController; + late final FocusNode _emailFocusNode; + late final FocusNode _passwordFocusNode; + late final FocusNode _serverEndpointFocusNode; + late final AnimationController _logoAnimationController; + + bool _isLoading = false; + bool _isLoadingServer = false; + bool _isOAuthEnabled = false; + bool _isPasswordLoginEnabled = false; + String _oAuthButtonLabel = 'OAuth'; + String? _serverEndpoint; + String? _warningMessage; + + @override + void initState() { + super.initState(); + _emailController = TextEditingController(); + _passwordController = TextEditingController(); + _serverEndpointController = TextEditingController(); + _emailFocusNode = FocusNode(); + _passwordFocusNode = FocusNode(); + _serverEndpointFocusNode = FocusNode(); + _logoAnimationController = AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat(); + + // Load saved server URL if available + WidgetsBinding.instance.addPostFrameCallback((_) { + final serverUrl = getServerUrl(); + if (serverUrl != null) { + _serverEndpointController.text = serverUrl; + } + }); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _serverEndpointController.dispose(); + _emailFocusNode.dispose(); + _passwordFocusNode.dispose(); + _serverEndpointFocusNode.dispose(); + _logoAnimationController.dispose(); + super.dispose(); + } + + Future _checkVersionMismatch() async { + try { + final serverInfo = ref.read(serverInfoProvider); + final packageInfo = await PackageInfo.fromPlatform(); + final appVersion = packageInfo.version; + final appMajorVersion = int.parse(appVersion.split('.')[0]); + final appMinorVersion = int.parse(appVersion.split('.')[1]); + final serverMajorVersion = serverInfo.serverVersion.major; + final serverMinorVersion = serverInfo.serverVersion.minor; + + setState(() { + _warningMessage = getVersionCompatibilityMessage( appMajorVersion, appMinorVersion, serverMajorVersion, serverMinorVersion, ); - } catch (error) { - warningMessage.value = 'Error checking version compatibility'; - } + }); + } catch (error) { + setState(() { + _warningMessage = 'Error checking version compatibility'; + }); + } + } + + Future _getServerAuthSettings() async { + final serverUrl = _serverEndpointController.text; + + if (serverUrl.isEmpty) { + ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error); + return; } - /// Fetch the server login credential and enables oAuth login if necessary - /// Returns true if successful, false otherwise - Future getServerAuthSettings() async { - final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text); - final serverUrl = punycodeEncodeUrl(sanitizeServerUrl); + try { + setState(() { + _isLoadingServer = true; + }); - // Guard empty URL - if (serverUrl.isEmpty) { - ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error); - } - - try { - isLoadingServer.value = true; - final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl); - - // Fetch and load server config and features - await ref.read(serverInfoProvider.notifier).getServerInfo(); - - final serverInfo = ref.read(serverInfoProvider); - final features = serverInfo.serverFeatures; - final config = serverInfo.serverConfig; - - isOauthEnable.value = features.oauthEnabled; - isPasswordLoginEnable.value = features.passwordLogin; - oAuthButtonLabel.value = config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth'; - - serverEndpoint.value = endpoint; - } on ApiException catch (e) { - ImmichToast.show( - context: context, - msg: e.message ?? 'login_form_api_exception'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); - isOauthEnable.value = false; - isPasswordLoginEnable.value = true; - isLoadingServer.value = false; - } on HandshakeException { - ImmichToast.show( - context: context, - msg: 'login_form_handshake_exception'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); - isOauthEnable.value = false; - isPasswordLoginEnable.value = true; - isLoadingServer.value = false; - } catch (e) { + final settings = await ref.read(authProvider.notifier).getServerAuthSettings(serverUrl); + if (settings == null) { ImmichToast.show( context: context, msg: 'login_form_server_error'.tr(), toastType: ToastType.error, gravity: ToastGravity.TOP, ); - isOauthEnable.value = false; - isPasswordLoginEnable.value = true; - isLoadingServer.value = false; - } - - isLoadingServer.value = false; - } - - useEffect(() { - final serverUrl = getServerUrl(); - if (serverUrl != null) { - serverEndpointController.text = serverUrl; - } - return null; - }, []); - - populateTestLoginInfo() { - emailController.text = 'demo@immich.app'; - passwordController.text = 'demo'; - serverEndpointController.text = 'https://demo.immich.app'; - } - - populateTestLoginInfo1() { - emailController.text = 'testuser@email.com'; - passwordController.text = 'password'; - serverEndpointController.text = 'http://10.1.15.216:2283/api'; - } - - Future handleSyncFlow() async { - final backgroundManager = ref.read(backgroundSyncProvider); - - await backgroundManager.syncLocal(full: true); - await backgroundManager.syncRemote(); - await backgroundManager.hashAssets(); - - if (Store.get(StoreKey.syncAlbums, false)) { - await backgroundManager.syncLinkedAlbum(); - } - } - - getManageMediaPermission() async { - final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission(); - if (!hasPermission) { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), - elevation: 5, - title: Text( - 'manage_media_access_title', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor), - ).tr(), - content: SingleChildScrollView( - child: ListBody( - children: [ - const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(), - const SizedBox(height: 4), - const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - 'cancel'.tr(), - style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), - ), - ), - TextButton( - onPressed: () { - ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); - Navigator.of(context).pop(); - }, - child: Text( - 'manage_media_access_settings'.tr(), - style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), - ), - ), - ], - ); - }, - ); - } - } - - bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false); - - login() async { - TextInput.finishAutofillContext(); - - isLoading.value = true; - - // Invalidate all api repository provider instance to take into account new access token - invalidateAllApiRepositoryProviders(ref); - - try { - final result = await ref.read(authProvider.notifier).login(emailController.text, passwordController.text); - - if (result.shouldChangePassword && !result.isAdmin) { - unawaited(context.pushRoute(const ChangePasswordRoute())); - } else { - final isBeta = Store.isBetaTimelineEnabled; - if (isBeta) { - await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - if (isSyncRemoteDeletionsMode()) { - await getManageMediaPermission(); - } - unawaited(handleSyncFlow()); - ref.read(websocketProvider.notifier).connect(); - unawaited(context.replaceRoute(const TabShellRoute())); - return; - } - unawaited(context.replaceRoute(const TabControllerRoute())); - } - } catch (error) { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); - } finally { - isLoading.value = false; - } - } - - String generateRandomString(int length) { - const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890'; - final random = Random.secure(); - return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length)))); - } - - List randomBytes(int length) { - final random = Random.secure(); - return List.generate(length, (i) => random.nextInt(256)); - } - - /// Per specification, the code verifier must be 43-128 characters long - /// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"] - /// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 - String randomCodeVerifier() { - return base64Url.encode(randomBytes(42)); - } - - Future generatePKCECodeChallenge(String codeVerifier) async { - var bytes = utf8.encode(codeVerifier); - var digest = sha256.convert(bytes); - return base64Url.encode(digest.bytes).replaceAll('=', ''); - } - - oAuthLogin() async { - var oAuthService = ref.watch(oAuthServiceProvider); - String? oAuthServerUrl; - - final state = generateRandomString(32); - - final codeVerifier = randomCodeVerifier(); - final codeChallenge = await generatePKCECodeChallenge(codeVerifier); - - try { - oAuthServerUrl = await oAuthService.getOAuthServerUrl( - sanitizeUrl(serverEndpointController.text), - state, - codeChallenge, - ); - - isLoading.value = true; - - // Invalidate all api repository provider instance to take into account new access token - invalidateAllApiRepositoryProviders(ref); - } catch (error, stack) { - log.severe('Error getting OAuth server Url: $error', stack); - - ImmichToast.show( - context: context, - msg: "login_form_failed_get_oauth_server_config".tr(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, - ); - isLoading.value = false; + _resetServerState(); return; } - if (oAuthServerUrl != null) { - try { - final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier); + setState(() { + _isOAuthEnabled = settings.isOAuthEnabled; + _isPasswordLoginEnabled = settings.isPasswordLoginEnabled; + _oAuthButtonLabel = settings.oAuthButtonText; + _serverEndpoint = settings.endpoint; + _isLoadingServer = false; + }); - if (loginResponseDto == null) { - return; - } + await _checkVersionMismatch(); + } on ApiException catch (e) { + ImmichToast.show( + context: context, + msg: e.message ?? 'login_form_api_exception'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); + _resetServerState(); + } on HandshakeException { + ImmichToast.show( + context: context, + msg: 'login_form_handshake_exception'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); + _resetServerState(); + } catch (e) { + ImmichToast.show( + context: context, + msg: 'login_form_server_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); + _resetServerState(); + } + } - log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}"); + void _resetServerState() { + setState(() { + _isOAuthEnabled = false; + _isPasswordLoginEnabled = true; + _isLoadingServer = false; + }); + } - final isSuccess = await ref - .watch(authProvider.notifier) - .saveAuthInfo(accessToken: loginResponseDto.accessToken); + void _populateTestLoginInfo() { + _emailController.text = 'demo@immich.app'; + _passwordController.text = 'demo'; + _serverEndpointController.text = 'https://demo.immich.app'; + } - if (isSuccess) { - isLoading.value = false; - final permission = ref.watch(galleryPermissionNotifier); - final isBeta = Store.isBetaTimelineEnabled; - if (!isBeta && (permission.isGranted || permission.isLimited)) { - unawaited(ref.watch(backupProvider.notifier).resumeBackup()); - } - if (isBeta) { - await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); - if (isSyncRemoteDeletionsMode()) { - await getManageMediaPermission(); - } - unawaited(handleSyncFlow()); - unawaited(context.replaceRoute(const TabShellRoute())); - return; - } - unawaited(context.replaceRoute(const TabControllerRoute())); - } - } catch (error, stack) { - log.severe('Error logging in with OAuth: $error', stack); + void _populateTestLoginInfo1() { + _emailController.text = 'testuser@email.com'; + _passwordController.text = 'password'; + _serverEndpointController.text = 'http://10.1.15.216:2283/api'; + } - ImmichToast.show( - context: context, - msg: error.toString(), - toastType: ToastType.error, - gravity: ToastGravity.TOP, + Future _handleSyncFlow() async { + final backgroundManager = ref.read(backgroundSyncProvider); + + await backgroundManager.syncLocal(full: true); + await backgroundManager.syncRemote(); + await backgroundManager.hashAssets(); + + if (Store.get(StoreKey.syncAlbums, false)) { + await backgroundManager.syncLinkedAlbum(); + } + } + + Future _getManageMediaPermission() async { + final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission(); + if (!hasPermission) { + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))), + elevation: 5, + title: Text( + 'manage_media_access_title', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor), + ).tr(), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(), + const SizedBox(height: 4), + const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'cancel'.tr(), + style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), + ), + ), + TextButton( + onPressed: () { + ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission(); + Navigator.of(context).pop(); + }, + child: Text( + 'manage_media_access_settings'.tr(), + style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor), + ), + ), + ], ); - } finally { - isLoading.value = false; - } + }, + ); + } + } + + bool _isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false); + + Future _login() async { + TextInput.finishAutofillContext(); + + setState(() { + _isLoading = true; + }); + + // Invalidate all api repository provider instance to take into account new access token + invalidateAllApiRepositoryProviders(ref); + + try { + final result = await ref.read(authProvider.notifier).login(_emailController.text, _passwordController.text); + + if (result.shouldChangePassword && !result.isAdmin) { + unawaited(context.pushRoute(const ChangePasswordRoute())); } else { + final isBeta = Store.isBetaTimelineEnabled; + if (isBeta) { + await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + if (_isSyncRemoteDeletionsMode()) { + await _getManageMediaPermission(); + } + unawaited(_handleSyncFlow()); + ref.read(websocketProvider.notifier).connect(); + unawaited(context.replaceRoute(const TabShellRoute())); + return; + } + unawaited(context.replaceRoute(const TabControllerRoute())); + } + } catch (error) { + ImmichToast.show( + context: context, + msg: "login_form_failed_login".tr(), + toastType: ToastType.error, + gravity: ToastGravity.TOP, + ); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + Future _oAuthLogin() async { + setState(() { + _isLoading = true; + }); + + // Invalidate all api repository provider instance to take into account new access token + invalidateAllApiRepositoryProviders(ref); + + try { + final oAuthData = await ref + .read(oAuthProvider.notifier) + .getOAuthLoginData(sanitizeUrl(_serverEndpointController.text)); + + if (oAuthData == null) { ImmichToast.show( context: context, msg: "login_form_failed_get_oauth_server_disable".tr(), toastType: ToastType.info, gravity: ToastGravity.TOP, ); - isLoading.value = false; + setState(() { + _isLoading = false; + }); return; } - } - buildSelectServer() { - const buttonRadius = 25.0; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ServerEndpointInput( - controller: serverEndpointController, - focusNode: serverEndpointFocusNode, - onSubmit: getServerAuthSettings, - ), - const SizedBox(height: 18), - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(buttonRadius), - bottomLeft: Radius.circular(buttonRadius), - ), - ), - ), - onPressed: () => context.pushRoute(const SettingsRoute()), - icon: const Icon(Icons.settings_rounded), - label: const Text(""), - ), - ), - const SizedBox(width: 1), - Expanded( - flex: 3, - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(buttonRadius), - bottomRight: Radius.circular(buttonRadius), - ), - ), - ), - onPressed: isLoadingServer.value ? null : getServerAuthSettings, - icon: const Icon(Icons.arrow_forward_rounded), - label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), - ), - ), - ], - ), - const SizedBox(height: 18), - if (isLoadingServer.value) const LoadingIcon(), - ], - ); - } + final loginResponseDto = await ref.read(oAuthProvider.notifier).completeOAuthLogin(oAuthData); - buildVersionCompatWarning() { - checkVersionMismatch(); - - if (warningMessage.value == null) { - return const SizedBox.shrink(); + if (loginResponseDto == null) { + setState(() { + _isLoading = false; + }); + return; } - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100, - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!), - ), - child: Text(warningMessage.value!, textAlign: TextAlign.center), - ), - ); + _log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}"); + + final isSuccess = await ref.read(authProvider.notifier).saveAuthInfo(accessToken: loginResponseDto.accessToken); + + if (isSuccess) { + setState(() { + _isLoading = false; + }); + final permission = ref.read(galleryPermissionNotifier); + final isBeta = Store.isBetaTimelineEnabled; + if (!isBeta && (permission.isGranted || permission.isLimited)) { + unawaited(ref.read(backupProvider.notifier).resumeBackup()); + } + if (isBeta) { + await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission(); + if (_isSyncRemoteDeletionsMode()) { + await _getManageMediaPermission(); + } + unawaited(_handleSyncFlow()); + unawaited(context.replaceRoute(const TabShellRoute())); + return; + } + unawaited(context.replaceRoute(const TabControllerRoute())); + } + } catch (error, stack) { + _log.severe('Error logging in with OAuth: $error', stack); + + ImmichToast.show(context: context, msg: error.toString(), toastType: ToastType.error, gravity: ToastGravity.TOP); + } finally { + setState(() { + _isLoading = false; + }); } + } - buildLogin() { - return AutofillGroup( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - buildVersionCompatWarning(), - Text( - sanitizeUrl(serverEndpointController.text), - style: context.textTheme.displaySmall, - textAlign: TextAlign.center, - ), - if (isPasswordLoginEnable.value) ...[ - const SizedBox(height: 18), - EmailInput( - controller: emailController, - focusNode: emailFocusNode, - onSubmit: passwordFocusNode.requestFocus, - ), - const SizedBox(height: 8), - PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login), - ], + void _goBack() { + setState(() { + _serverEndpoint = null; + }); + } - // Note: This used to have an AnimatedSwitcher, but was removed - // because of https://github.com/flutter/flutter/issues/120874 - isLoading.value - ? const LoadingIcon() - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 18), - if (isPasswordLoginEnable.value) LoginButton(onPressed: login), - if (isOauthEnable.value) ...[ - if (isPasswordLoginEnable.value) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black), - ), - OAuthLoginButton( - serverEndpointController: serverEndpointController, - buttonLabel: oAuthButtonLabel.value, - isLoading: isLoading, - onPressed: oAuthLogin, - ), - ], - ], - ), - if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()), - const SizedBox(height: 12), - TextButton.icon( - icon: const Icon(Icons.arrow_back), - onPressed: () => serverEndpoint.value = null, - label: const Text('back').tr(), - ), - ], - ), - ); - } - - final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin(); + @override + Widget build(BuildContext context) { + final serverSelectionOrLogin = _serverEndpoint == null + ? ServerSelectionForm( + serverEndpointController: _serverEndpointController, + serverEndpointFocusNode: _serverEndpointFocusNode, + isLoading: _isLoadingServer, + onSubmit: _getServerAuthSettings, + ) + : LoginCredentialsForm( + emailController: _emailController, + passwordController: _passwordController, + serverEndpointController: _serverEndpointController, + emailFocusNode: _emailFocusNode, + passwordFocusNode: _passwordFocusNode, + isLoading: _isLoading, + isOAuthEnabled: _isOAuthEnabled, + isPasswordLoginEnabled: _isPasswordLoginEnabled, + oAuthButtonLabel: _oAuthButtonLabel, + warningMessage: _warningMessage, + onLogin: _login, + onOAuthLogin: _oAuthLogin, + onBack: _goBack, + ); return LayoutBuilder( builder: (context, constraints) { @@ -532,20 +418,19 @@ class LoginForm extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ GestureDetector( - onDoubleTap: () => populateTestLoginInfo(), - onLongPress: () => populateTestLoginInfo1(), + onDoubleTap: _populateTestLoginInfo, + onLongPress: _populateTestLoginInfo1, child: RotationTransition( - turns: logoAnimationController, + turns: _logoAnimationController, child: const ImmichLogo(heroTag: 'logo'), ), ), const Padding(padding: EdgeInsets.only(top: 8.0, bottom: 16), child: ImmichTitleText()), ], ), - // Note: This used to have an AnimatedSwitcher, but was removed // because of https://github.com/flutter/flutter/issues/120874 - Form(key: loginFormKey, child: serverSelectionOrLogin), + Form(key: _loginFormKey, child: serverSelectionOrLogin), ], ), ), diff --git a/mobile/lib/widgets/forms/login/o_auth_login_button.dart b/mobile/lib/widgets/forms/login/o_auth_login_button.dart index 2d9b603b3c..fae0d035ed 100644 --- a/mobile/lib/widgets/forms/login/o_auth_login_button.dart +++ b/mobile/lib/widgets/forms/login/o_auth_login_button.dart @@ -1,23 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -class OAuthLoginButton extends ConsumerWidget { +class OAuthLoginButton extends StatelessWidget { final TextEditingController serverEndpointController; - final ValueNotifier isLoading; final String buttonLabel; - final Function() onPressed; + final VoidCallback onPressed; const OAuthLoginButton({ super.key, required this.serverEndpointController, - required this.isLoading, required this.buttonLabel, required this.onPressed, }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: context.primaryColor.withAlpha(230), diff --git a/mobile/lib/widgets/forms/login/password_input.dart b/mobile/lib/widgets/forms/login/password_input.dart index 5cdfcc9567..1ba1833224 100644 --- a/mobile/lib/widgets/forms/login/password_input.dart +++ b/mobile/lib/widgets/forms/login/password_input.dart @@ -1,36 +1,45 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -class PasswordInput extends HookConsumerWidget { +class PasswordInput extends StatefulWidget { final TextEditingController controller; final FocusNode? focusNode; - final Function()? onSubmit; + final VoidCallback? onSubmit; const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit}); @override - Widget build(BuildContext context, WidgetRef ref) { - final isPasswordVisible = useState(false); + State createState() => _PasswordInputState(); +} +class _PasswordInputState extends State { + bool _isPasswordVisible = false; + + void _togglePasswordVisibility() { + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); + } + + @override + Widget build(BuildContext context) { return TextFormField( - obscureText: !isPasswordVisible.value, - controller: controller, + obscureText: !_isPasswordVisible, + controller: widget.controller, decoration: InputDecoration( labelText: 'password'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_password_hint'.tr(), hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14), suffixIcon: IconButton( - onPressed: () => isPasswordVisible.value = !isPasswordVisible.value, - icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp), + onPressed: _togglePasswordVisibility, + icon: Icon(_isPasswordVisible ? Icons.visibility_off_sharp : Icons.visibility_sharp), ), ), autofillHints: const [AutofillHints.password], keyboardType: TextInputType.text, - onFieldSubmitted: (_) => onSubmit?.call(), - focusNode: focusNode, + onFieldSubmitted: (_) => widget.onSubmit?.call(), + focusNode: widget.focusNode, textInputAction: TextInputAction.go, ); } diff --git a/mobile/lib/widgets/forms/login/server_selection_form.dart b/mobile/lib/widgets/forms/login/server_selection_form.dart new file mode 100644 index 0000000000..b19b5e661f --- /dev/null +++ b/mobile/lib/widgets/forms/login/server_selection_form.dart @@ -0,0 +1,78 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/forms/login/loading_icon.dart'; +import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart'; + +class ServerSelectionForm extends StatelessWidget { + final TextEditingController serverEndpointController; + final FocusNode serverEndpointFocusNode; + final bool isLoading; + final VoidCallback onSubmit; + + const ServerSelectionForm({ + super.key, + required this.serverEndpointController, + required this.serverEndpointFocusNode, + required this.isLoading, + required this.onSubmit, + }); + + static const double _buttonRadius = 25.0; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ServerEndpointInput( + controller: serverEndpointController, + focusNode: serverEndpointFocusNode, + onSubmit: onSubmit, + ), + const SizedBox(height: 18), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(_buttonRadius), + bottomLeft: Radius.circular(_buttonRadius), + ), + ), + ), + onPressed: () => context.pushRoute(const SettingsRoute()), + icon: const Icon(Icons.settings_rounded), + label: const Text(""), + ), + ), + const SizedBox(width: 1), + Expanded( + flex: 3, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(_buttonRadius), + bottomRight: Radius.circular(_buttonRadius), + ), + ), + ), + onPressed: isLoading ? null : onSubmit, + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(), + ), + ), + ], + ), + const SizedBox(height: 18), + if (isLoading) const LoadingIcon(), + ], + ); + } +} diff --git a/mobile/lib/widgets/forms/login/version_compatibility_warning.dart b/mobile/lib/widgets/forms/login/version_compatibility_warning.dart new file mode 100644 index 0000000000..e3875c1f6f --- /dev/null +++ b/mobile/lib/widgets/forms/login/version_compatibility_warning.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class VersionCompatibilityWarning extends StatelessWidget { + final String message; + + const VersionCompatibilityWarning({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!), + ), + child: Text(message, textAlign: TextAlign.center), + ), + ); + } +}