1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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
This commit is contained in:
martyfuhry 2023-03-05 23:46:38 -05:00 committed by GitHub
parent 2ca560ebf8
commit a4c215751e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 401 additions and 262 deletions

View File

@ -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.",

View File

@ -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,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<bool> 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<bool> 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,