From a4c215751e382c0eb4162cee01a27f18161af3cb Mon Sep 17 00:00:00 2001 From: martyfuhry Date: Sun, 5 Mar 2023 23:46:38 -0500 Subject: [PATCH] feat(mobile): Enter server first for login (#1952) * improves login form * login form improvements * correctly trim server endpoint controller text when logging in * don't show loading while fetching server info * fixes get server login credentials * fixes up sign in form * error handling * fixed layout * removed placeholder text --- mobile/assets/i18n/en-US.json | 5 +- mobile/lib/modules/login/ui/login_form.dart | 532 ++++++++++++-------- 2 files changed, 338 insertions(+), 199 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index bf5d3e6ad5..192ac99622 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -157,8 +157,11 @@ "login_form_failed_login": "Error logging you in, check server URL, email and password", "login_form_label_email": "Email", "login_form_label_password": "Password", - "login_form_password_hint": "password", + "login_form_password_hint": "Password", "login_form_save_login": "Stay logged in", + "login_form_server_empty": "Enter a server URL.", + "login_form_server_error": "Could not connect to server.", + "login_form_api_exception": "API exception. Please check the server URL and try again.", "monthly_title_text_date_format": "MMMM y", "notification_permission_dialog_cancel": "Cancel", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index a324ce17b2..fec799717e 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -32,48 +32,78 @@ class LoginForm extends HookConsumerWidget { final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty); final apiService = ref.watch(apiServiceProvider); + final emailFocusNode = useFocusNode(); + final passwordFocusNode = useFocusNode(); final serverEndpointFocusNode = useFocusNode(); final isLoading = useState(false); + final isLoadingServer = useState(false); final isOauthEnable = useState(false); final oAuthButtonLabel = useState('OAuth'); final logoAnimationController = useAnimationController( duration: const Duration(seconds: 60), )..repeat(); - getServeLoginConfig() async { - if (!serverEndpointFocusNode.hasFocus) { - var serverUrl = serverEndpointController.text.trim(); + final ValueNotifier serverEndpoint = useState(null); - try { - if (serverUrl.isNotEmpty) { - isLoading.value = true; - final serverEndpoint = - await apiService.resolveAndSetEndpoint(serverUrl.toString()); + /// Fetch the server login credential and enables oAuth login if necessary + /// Returns true if successful, false otherwise + Future getServerLoginCredential() async { + final serverUrl = serverEndpointController.text.trim(); - var loginConfig = await apiService.oAuthApi.generateConfig( - OAuthConfigDto(redirectUri: serverEndpoint), - ); + // Guard empty URL + if (serverUrl.isEmpty) { + ImmichToast.show( + context: context, + msg: "login_form_server_empty".tr(), + toastType: ToastType.error, + ); + + return false; + } - if (loginConfig != null) { - isOauthEnable.value = loginConfig.enabled; - oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth'; - } else { - isOauthEnable.value = false; - } + try { + isLoadingServer.value = true; + final endpoint = + await apiService.resolveAndSetEndpoint(serverUrl); - isLoading.value = false; - } - } catch (_) { - isLoading.value = false; + final loginConfig = await apiService.oAuthApi.generateConfig( + OAuthConfigDto(redirectUri: serverUrl), + ); + + if (loginConfig != null) { + isOauthEnable.value = loginConfig.enabled; + oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth'; + } else { isOauthEnable.value = false; } - } + + serverEndpoint.value = endpoint; + } on ApiException catch (e) { + ImmichToast.show( + context: context, + msg: e.message ?? 'login_form_api_exception'.tr(), + toastType: ToastType.error, + ); + isOauthEnable.value = false; + isLoadingServer.value = false; + return false; + } catch (e) { + ImmichToast.show( + context: context, + msg: 'login_form_server_error'.tr(), + toastType: ToastType.error, + ); + isOauthEnable.value = false; + isLoadingServer.value = false; + return false; + } + + isLoadingServer.value = false; + return true; } useEffect( () { - serverEndpointFocusNode.addListener(getServeLoginConfig); - var loginInfo = Hive.box(hiveLoginInfoBox) .get(savedLoginInfoKey); @@ -83,7 +113,6 @@ class LoginForm extends HookConsumerWidget { serverEndpointController.text = loginInfo.serverUrl; } - getServeLoginConfig(); return null; }, [], @@ -95,86 +124,260 @@ class LoginForm extends HookConsumerWidget { serverEndpointController.text = 'http://10.1.15.216:2283/api'; } - return Center( - child: ConstrainedBox( + login() async { + // Start loading + isLoading.value = true; + + // This will remove current cache asset state of previous user login. + ref.read(assetProvider.notifier).clearAllAsset(); + + try { + final isAuthenticated = + await ref.read(authenticationProvider.notifier).login( + usernameController.text, + passwordController.text, + serverEndpointController.text.trim(), + ); + if (isAuthenticated) { + // Resume backup (if enable) then navigate + if (ref.read(authenticationProvider).shouldChangePassword && + !ref.read(authenticationProvider).isAdmin) { + AutoRouter.of(context).push(const ChangePasswordRoute()); + } else { + final hasPermission = await ref + .read(galleryPermissionNotifier.notifier) + .hasPermission; + if (hasPermission) { + // Don't resume the backup until we have gallery permission + ref.read(backupProvider.notifier).resumeBackup(); + } + AutoRouter.of(context).replace(const TabControllerRoute()); + } + } else { + ImmichToast.show( + context: context, + msg: "login_form_failed_login".tr(), + toastType: ToastType.error, + ); + } + } finally { + // Make sure we stop loading + isLoading.value = false; + } + } + + + oAuthLogin() async { + var oAuthService = ref.watch(oAuthServiceProvider); + ref.watch(assetProvider.notifier).clearAllAsset(); + OAuthConfigResponseDto? oAuthServerConfig; + + try { + oAuthServerConfig = await oAuthService + .getOAuthServerConfig(serverEndpointController.text); + + isLoading.value = true; + } catch (e) { + ImmichToast.show( + context: context, + msg: "login_form_failed_get_oauth_server_config".tr(), + toastType: ToastType.error, + ); + isLoading.value = false; + return; + } + + if (oAuthServerConfig != null && oAuthServerConfig.enabled) { + var loginResponseDto = + await oAuthService.oAuthLogin(oAuthServerConfig.url!); + + if (loginResponseDto != null) { + var isSuccess = await ref + .watch(authenticationProvider.notifier) + .setSuccessLoginInfo( + accessToken: loginResponseDto.accessToken, + serverUrl: serverEndpointController.text, + ); + + if (isSuccess) { + isLoading.value = false; + final permission = ref.watch(galleryPermissionNotifier); + if (permission.isGranted || permission.isLimited) { + ref.watch(backupProvider.notifier).resumeBackup(); + } + AutoRouter.of(context).replace( + const TabControllerRoute(), + ); + } else { + ImmichToast.show( + context: context, + msg: "login_form_failed_login".tr(), + toastType: ToastType.error, + ); + } + } + + isLoading.value = false; + } else { + ImmichToast.show( + context: context, + msg: "login_form_failed_get_oauth_server_disable".tr(), + toastType: ToastType.info, + ); + isLoading.value = false; + return; + } + } + + buildSelectServer() { + return ConstrainedBox( + key: const ValueKey('server'), constraints: const BoxConstraints(maxWidth: 300), - child: SingleChildScrollView( - child: AutofillGroup( - child: Wrap( - spacing: 16, - runSpacing: 16, - alignment: WrapAlignment.center, - children: [ - GestureDetector( - onDoubleTap: () => populateTestLoginInfo(), - child: RotationTransition( - turns: logoAnimationController, - child: const ImmichLogo( - heroTag: 'logo', - ), - ), - ), - const ImmichTitleText(), - EmailInput(controller: usernameController), - PasswordInput(controller: passwordController), - ServerEndpointInput( - controller: serverEndpointController, - focusNode: serverEndpointFocusNode, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ServerEndpointInput( + controller: serverEndpointController, + focusNode: serverEndpointFocusNode, + onSubmit: getServerLoginCredential, + ), + const SizedBox(height: 18), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: isLoadingServer.value ? null : getServerLoginCredential, + icon: const Icon(Icons.arrow_forward_rounded), + label: const Text( + 'Next', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ), + if (isLoadingServer.value) + const Padding( + padding: EdgeInsets.only(top: 18.0), + child: Center( + child: CircularProgressIndicator(), ), - if (isLoading.value) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ), - if (!isLoading.value) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 18), - LoginButton( - emailController: usernameController, - passwordController: passwordController, - serverEndpointController: serverEndpointController, + ), + ], + ), + ); + } + + buildLogin() { + return ConstrainedBox( + key: const ValueKey('login'), + constraints: const BoxConstraints(maxWidth: 300), + child: AutofillGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + serverEndpointController.text, + style: Theme.of(context).textTheme.displaySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 18), + EmailInput( + controller: usernameController, + focusNode: emailFocusNode, + onSubmit: passwordFocusNode.requestFocus, + ), + const SizedBox(height: 8), + PasswordInput( + controller: passwordController, + focusNode: passwordFocusNode, + onSubmit: login, + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: isLoading.value + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, ), - if (isOauthEnable.value) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 18), + LoginButton(onPressed: login), + if (isOauthEnable.value) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Divider( + color: + Brightness.dark == Theme.of(context).brightness + ? Colors.white + : Colors.black, + ), ), - child: Divider( - color: - Brightness.dark == Theme.of(context).brightness - ? Colors.white - : Colors.black, + OAuthLoginButton( + serverEndpointController: serverEndpointController, + buttonLabel: oAuthButtonLabel.value, + isLoading: isLoading, + onPressed: oAuthLogin, ), - ), - OAuthLoginButton( - serverEndpointController: serverEndpointController, - buttonLabel: oAuthButtonLabel.value, - isLoading: isLoading, - onLoginSuccess: () { - isLoading.value = false; - final permission = ref.watch(galleryPermissionNotifier); - if (permission.isGranted || permission.isLimited) { - ref.watch(backupProvider.notifier).resumeBackup(); - } - AutoRouter.of(context).replace( - const TabControllerRoute(), - ); - }, - ), + ], ], - ], - ) - ], - ), + ), + ), + const SizedBox(height: 12), + TextButton.icon( + icon: const Icon(Icons.arrow_back), + onPressed: () => serverEndpoint.value = null, + label: const Text('Back'), + ), + ], ), ), - ), + ); + } + final child = serverEndpoint.value == null + ? buildSelectServer() + : buildLogin(); + + return LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: constraints.maxHeight / 5, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onDoubleTap: () => populateTestLoginInfo(), + child: RotationTransition( + turns: logoAnimationController, + child: const ImmichLogo( + heroTag: 'logo', + ), + ), + ), + const ImmichTitleText(), + ], + ), + const SizedBox(height: 18), + AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: child, + ), + ], + ), + ); + }, ); } } @@ -182,10 +385,13 @@ class LoginForm extends HookConsumerWidget { class ServerEndpointInput extends StatelessWidget { final TextEditingController controller; final FocusNode focusNode; + final Function()? onSubmit; + const ServerEndpointInput({ Key? key, required this.controller, required this.focusNode, + this.onSubmit, }) : super(key: key); String? _validateInput(String? url) { @@ -218,14 +424,23 @@ class ServerEndpointInput extends StatelessWidget { autofillHints: const [AutofillHints.url], keyboardType: TextInputType.url, autocorrect: false, + onFieldSubmitted: (_) => onSubmit?.call(), + textInputAction: TextInputAction.go, ); } } class EmailInput extends StatelessWidget { final TextEditingController controller; + final FocusNode? focusNode; + final Function()? onSubmit; - const EmailInput({Key? key, required this.controller}) : super(key: key); + const EmailInput({ + Key? key, + required this.controller, + this.focusNode, + this.onSubmit, + }) : super(key: key); String? _validateInput(String? email) { if (email == null || email == '') return null; @@ -240,6 +455,7 @@ class EmailInput extends StatelessWidget { @override Widget build(BuildContext context) { return TextFormField( + autofocus: true, controller: controller, decoration: InputDecoration( labelText: 'login_form_label_email'.tr(), @@ -250,14 +466,24 @@ class EmailInput extends StatelessWidget { autovalidateMode: AutovalidateMode.always, autofillHints: const [AutofillHints.email], keyboardType: TextInputType.emailAddress, + onFieldSubmitted: (_) => onSubmit?.call(), + focusNode: focusNode, + textInputAction: TextInputAction.next, ); } } class PasswordInput extends StatelessWidget { final TextEditingController controller; + final FocusNode? focusNode; + final Function()? onSubmit; - const PasswordInput({Key? key, required this.controller}) : super(key: key); + const PasswordInput({ + Key? key, + required this.controller, + this.focusNode, + this.onSubmit, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -271,20 +497,19 @@ class PasswordInput extends StatelessWidget { ), autofillHints: const [AutofillHints.password], keyboardType: TextInputType.text, + onFieldSubmitted: (_) => onSubmit?.call(), + focusNode: focusNode, + textInputAction: TextInputAction.go, ); } } class LoginButton extends ConsumerWidget { - final TextEditingController emailController; - final TextEditingController passwordController; - final TextEditingController serverEndpointController; + final Function() onPressed; const LoginButton({ Key? key, - required this.emailController, - required this.passwordController, - required this.serverEndpointController, + required this.onPressed, }) : super(key: key); @override @@ -293,40 +518,7 @@ class LoginButton extends ConsumerWidget { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 12), ), - onPressed: () async { - // This will remove current cache asset state of previous user login. - ref.read(assetProvider.notifier).clearAllAsset(); - - var isAuthenticated = - await ref.read(authenticationProvider.notifier).login( - emailController.text, - passwordController.text, - serverEndpointController.text, - ); - - if (isAuthenticated) { - // Resume backup (if enable) then navigate - if (ref.read(authenticationProvider).shouldChangePassword && - !ref.read(authenticationProvider).isAdmin) { - AutoRouter.of(context).push(const ChangePasswordRoute()); - } else { - final hasPermission = await ref - .read(galleryPermissionNotifier.notifier) - .hasPermission; - if (hasPermission) { - // Don't resume the backup until we have gallery permission - ref.read(backupProvider.notifier).resumeBackup(); - } - AutoRouter.of(context).replace(const TabControllerRoute()); - } - } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - ); - } - }, + onPressed: onPressed, icon: const Icon(Icons.login_rounded), label: const Text( "login_form_button_text", @@ -339,82 +531,26 @@ class LoginButton extends ConsumerWidget { class OAuthLoginButton extends ConsumerWidget { final TextEditingController serverEndpointController; final ValueNotifier isLoading; - final VoidCallback onLoginSuccess; final String buttonLabel; + final Function() onPressed; const OAuthLoginButton({ Key? key, required this.serverEndpointController, required this.isLoading, - required this.onLoginSuccess, required this.buttonLabel, + required this.onPressed, }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { - var oAuthService = ref.watch(oAuthServiceProvider); - - void performOAuthLogin() async { - ref.watch(assetProvider.notifier).clearAllAsset(); - OAuthConfigResponseDto? oAuthServerConfig; - - try { - oAuthServerConfig = await oAuthService - .getOAuthServerConfig(serverEndpointController.text); - - isLoading.value = true; - } catch (e) { - ImmichToast.show( - context: context, - msg: "login_form_failed_get_oauth_server_config".tr(), - toastType: ToastType.error, - ); - isLoading.value = false; - return; - } - - if (oAuthServerConfig != null && oAuthServerConfig.enabled) { - var loginResponseDto = - await oAuthService.oAuthLogin(oAuthServerConfig.url!); - - if (loginResponseDto != null) { - var isSuccess = await ref - .watch(authenticationProvider.notifier) - .setSuccessLoginInfo( - accessToken: loginResponseDto.accessToken, - serverUrl: serverEndpointController.text, - ); - - if (isSuccess) { - isLoading.value = false; - onLoginSuccess(); - } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_login".tr(), - toastType: ToastType.error, - ); - } - } - - isLoading.value = false; - } else { - ImmichToast.show( - context: context, - msg: "login_form_failed_get_oauth_server_disable".tr(), - toastType: ToastType.info, - ); - isLoading.value = false; - return; - } - } return ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: Theme.of(context).primaryColor.withAlpha(230), padding: const EdgeInsets.symmetric(vertical: 12), ), - onPressed: performOAuthLogin, + onPressed: onPressed, icon: const Icon(Icons.pin_rounded), label: Text( buttonLabel,