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,215 +124,20 @@ class LoginForm extends HookConsumerWidget { serverEndpointController.text = 'http://10.1.15.216:2283/api'; } - return Center( - child: ConstrainedBox( - 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, - ), - 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, - ), - if (isOauthEnable.value) ...[ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Divider( - color: - Brightness.dark == Theme.of(context).brightness - ? Colors.white - : Colors.black, - ), - ), - 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(), - ); - }, - ), - ], - ], - ) - ], - ), - ), - ), - ), - ); - } -} + login() async { + // Start loading + isLoading.value = true; -class ServerEndpointInput extends StatelessWidget { - final TextEditingController controller; - final FocusNode focusNode; - const ServerEndpointInput({ - Key? key, - required this.controller, - required this.focusNode, - }) : super(key: key); - - String? _validateInput(String? url) { - if (url == null || url.isEmpty) return null; - - final parsedUrl = Uri.tryParse(sanitizeUrl(url)); - if (parsedUrl == null || - !parsedUrl.isAbsolute || - !parsedUrl.scheme.startsWith("http") || - parsedUrl.host.isEmpty) { - return 'login_form_err_invalid_url'.tr(); - } - - return null; - } - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - decoration: InputDecoration( - labelText: 'login_form_endpoint_url'.tr(), - border: const OutlineInputBorder(), - hintText: 'login_form_endpoint_hint'.tr(), - errorMaxLines: 4, - ), - validator: _validateInput, - autovalidateMode: AutovalidateMode.always, - focusNode: focusNode, - autofillHints: const [AutofillHints.url], - keyboardType: TextInputType.url, - autocorrect: false, - ); - } -} - -class EmailInput extends StatelessWidget { - final TextEditingController controller; - - const EmailInput({Key? key, required this.controller}) : super(key: key); - - String? _validateInput(String? email) { - if (email == null || email == '') return null; - if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr(); - if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr(); - if (email.contains(' ') || !email.contains('@')) { - return 'login_form_err_invalid_email'.tr(); - } - return null; - } - - @override - Widget build(BuildContext context) { - return TextFormField( - controller: controller, - decoration: InputDecoration( - labelText: 'login_form_label_email'.tr(), - border: const OutlineInputBorder(), - hintText: 'login_form_email_hint'.tr(), - ), - validator: _validateInput, - autovalidateMode: AutovalidateMode.always, - autofillHints: const [AutofillHints.email], - keyboardType: TextInputType.emailAddress, - ); - } -} - -class PasswordInput extends StatelessWidget { - final TextEditingController controller; - - const PasswordInput({Key? key, required this.controller}) : super(key: key); - - @override - Widget build(BuildContext context) { - return TextFormField( - obscureText: true, - controller: controller, - decoration: InputDecoration( - labelText: 'login_form_label_password'.tr(), - border: const OutlineInputBorder(), - hintText: 'login_form_password_hint'.tr(), - ), - autofillHints: const [AutofillHints.password], - keyboardType: TextInputType.text, - ); - } -} - -class LoginButton extends ConsumerWidget { - final TextEditingController emailController; - final TextEditingController passwordController; - final TextEditingController serverEndpointController; - - const LoginButton({ - Key? key, - required this.emailController, - required this.passwordController, - required this.serverEndpointController, - }) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ElevatedButton.icon( - 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, - ); + // 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 && @@ -326,35 +160,15 @@ class LoginButton extends ConsumerWidget { toastType: ToastType.error, ); } - }, - icon: const Icon(Icons.login_rounded), - label: const Text( - "login_form_button_text", - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ).tr(), - ); - } -} + } finally { + // Make sure we stop loading + isLoading.value = false; + } + } -class OAuthLoginButton extends ConsumerWidget { - final TextEditingController serverEndpointController; - final ValueNotifier isLoading; - final VoidCallback onLoginSuccess; - final String buttonLabel; - const OAuthLoginButton({ - Key? key, - required this.serverEndpointController, - required this.isLoading, - required this.onLoginSuccess, - required this.buttonLabel, - }) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - var oAuthService = ref.watch(oAuthServiceProvider); - - void performOAuthLogin() async { + oAuthLogin() async { + var oAuthService = ref.watch(oAuthServiceProvider); ref.watch(assetProvider.notifier).clearAllAsset(); OAuthConfigResponseDto? oAuthServerConfig; @@ -387,7 +201,13 @@ class OAuthLoginButton extends ConsumerWidget { if (isSuccess) { isLoading.value = false; - onLoginSuccess(); + 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, @@ -409,12 +229,328 @@ class OAuthLoginButton extends ConsumerWidget { } } + buildSelectServer() { + return ConstrainedBox( + key: const ValueKey('server'), + constraints: const BoxConstraints(maxWidth: 300), + 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(), + ), + ), + ], + ), + ); + } + + 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, + ), + ) + : 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, + ), + ), + OAuthLoginButton( + serverEndpointController: serverEndpointController, + buttonLabel: oAuthButtonLabel.value, + isLoading: isLoading, + onPressed: oAuthLogin, + ), + ], + ], + ), + ), + 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, + ), + ], + ), + ); + }, + ); + } +} + +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) { + if (url == null || url.isEmpty) return null; + + final parsedUrl = Uri.tryParse(sanitizeUrl(url)); + if (parsedUrl == null || + !parsedUrl.isAbsolute || + !parsedUrl.scheme.startsWith("http") || + parsedUrl.host.isEmpty) { + return 'login_form_err_invalid_url'.tr(); + } + + return null; + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: 'login_form_endpoint_url'.tr(), + border: const OutlineInputBorder(), + hintText: 'login_form_endpoint_hint'.tr(), + errorMaxLines: 4, + ), + validator: _validateInput, + autovalidateMode: AutovalidateMode.always, + focusNode: focusNode, + 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, + this.focusNode, + this.onSubmit, + }) : super(key: key); + + String? _validateInput(String? email) { + if (email == null || email == '') return null; + if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr(); + if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr(); + if (email.contains(' ') || !email.contains('@')) { + return 'login_form_err_invalid_email'.tr(); + } + return null; + } + + @override + Widget build(BuildContext context) { + return TextFormField( + autofocus: true, + controller: controller, + decoration: InputDecoration( + labelText: 'login_form_label_email'.tr(), + border: const OutlineInputBorder(), + hintText: 'login_form_email_hint'.tr(), + ), + validator: _validateInput, + 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, + this.focusNode, + this.onSubmit, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return TextFormField( + obscureText: true, + controller: controller, + decoration: InputDecoration( + labelText: 'login_form_label_password'.tr(), + border: const OutlineInputBorder(), + hintText: 'login_form_password_hint'.tr(), + ), + autofillHints: const [AutofillHints.password], + keyboardType: TextInputType.text, + onFieldSubmitted: (_) => onSubmit?.call(), + focusNode: focusNode, + textInputAction: TextInputAction.go, + ); + } +} + +class LoginButton extends ConsumerWidget { + final Function() onPressed; + + const LoginButton({ + Key? key, + required this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onPressed, + icon: const Icon(Icons.login_rounded), + label: const Text( + "login_form_button_text", + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ); + } +} + +class OAuthLoginButton extends ConsumerWidget { + final TextEditingController serverEndpointController; + final ValueNotifier isLoading; + final String buttonLabel; + final Function() onPressed; + + const OAuthLoginButton({ + Key? key, + required this.serverEndpointController, + required this.isLoading, + required this.buttonLabel, + required this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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,