Browse Source

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
martyfuhry 2 years ago
parent
commit
a4c215751e
2 changed files with 338 additions and 199 deletions
  1. 4 1
      mobile/assets/i18n/en-US.json
  2. 334 198
      mobile/lib/modules/login/ui/login_form.dart

+ 4 - 1
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.",

+ 334 - 198
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<bool>(false);
+    final isLoadingServer = useState<bool>(false);
     final isOauthEnable = useState<bool>(false);
     final oAuthButtonLabel = useState<String>('OAuth');
     final logoAnimationController = useAnimationController(
       duration: const Duration(seconds: 60),
     )..repeat();
 
-    getServeLoginConfig() async {
-      if (!serverEndpointFocusNode.hasFocus) {
-        var serverUrl = serverEndpointController.text.trim();
+    final ValueNotifier<String?> serverEndpoint = useState<String?>(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<bool> 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<HiveSavedLoginInfo>(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<bool> 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,