From b3e51cc8492ceedec92f5087f8c25b5543e6c77b Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 20 Nov 2022 11:43:10 -0600 Subject: [PATCH] feat(mobile) Add OAuth Login On Mobile (#990) * Added return type for oauth/callback * Remove console.log * Redirect app * Wording * Added loading state change * Added OAuth login on mobile * Return correct status for correct redirection * Auto discovery OAuth Login --- docs/docs/usage/img/authentik-redirect.png | Bin 0 -> 52213 bytes docs/docs/usage/oauth.md | 12 +- mobile/android/app/build.gradle | 2 +- .../android/app/src/main/AndroidManifest.xml | 19 +- mobile/assets/i18n/en-US.json | 4 +- mobile/ios/Podfile.lock | 6 + .../models/hive_saved_login_info.model.dart | 10 +- .../models/hive_saved_login_info.model.g.dart | 9 +- .../providers/authentication.provider.dart | 133 +++++------ .../login/providers/oauth.provider.dart | 6 + .../modules/login/services/oauth.service.dart | 39 ++++ mobile/lib/modules/login/ui/login_form.dart | 209 ++++++++++++++++-- mobile/lib/shared/services/api.service.dart | 2 + mobile/lib/shared/ui/immich_toast.dart | 3 +- mobile/lib/shared/views/splash_screen.dart | 30 +-- .../openapi/lib/model/user_response_dto.dart | 94 ++++---- mobile/pubspec.lock | 7 + mobile/pubspec.yaml | 1 + .../src/api-v1/oauth/oauth.controller.ts | 6 +- 19 files changed, 443 insertions(+), 149 deletions(-) create mode 100644 docs/docs/usage/img/authentik-redirect.png create mode 100644 mobile/lib/modules/login/providers/oauth.provider.dart create mode 100644 mobile/lib/modules/login/services/oauth.service.dart diff --git a/docs/docs/usage/img/authentik-redirect.png b/docs/docs/usage/img/authentik-redirect.png new file mode 100644 index 0000000000000000000000000000000000000000..c16c03bab5cb8310a291f7cce5b5a627fad1675c GIT binary patch literal 52213 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 literal 0 HcmV?d00001 diff --git a/docs/docs/usage/oauth.md b/docs/docs/usage/oauth.md index c8dff35908..30cd842bff 100644 --- a/docs/docs/usage/oauth.md +++ b/docs/docs/usage/oauth.md @@ -28,9 +28,17 @@ Before enabling OAuth in Immich, a new client application needs to be configured 2. Configure Redirect URIs/Origins - 1. The **Sign-in redirect URIs** should include: + The **Sign-in redirect URIs** should include: - - All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`) + * All URLs that will be used to access the login page of the Immich web client (eg. `http://localhost:2283/auth/login`, `http://192.168.0.200:2283/auth/login`, `https://immich.example.com/auth/login`) + * Mobile app redirect URL `app.immich:/` + +:::caution +You **MUST** include `app.immich:/` as the redirect URI for iOS and Android mobile app to work properly. + +**Authentik example** + +::: ## Enable OAuth diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 54234e4b08..5aae5cfa69 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) { android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 36a967da6c..7363e4999f 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -12,15 +12,26 @@ + + + + + + + + + + - + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + tools:node="remove"> diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 826f5d017f..fd3dadcadd 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -109,7 +109,9 @@ "login_form_err_invalid_email": "Invalid Email", "login_form_err_leading_whitespace": "Leading whitespace", "login_form_err_trailing_whitespace": "Trailing whitespace", - "login_form_failed_login": "Error logging you in, check server url, email and password", + "login_form_failed_login": "Error logging you in, check server URL, email and password", + "login_form_failed_get_oauth_server_config": "Error logging using OAuth, check server URL", + "login_form_failed_get_oauth_server_disable": "OAuth feature is not available on this server", "login_form_label_email": "Email", "login_form_label_password": "Password", "login_form_password_hint": "password", diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 7310ec8756..ffa1e57887 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,6 +3,8 @@ PODS: - flutter_udid (0.0.1): - Flutter - SAMKeychain + - flutter_web_auth (0.5.0): + - Flutter - fluttertoast (0.0.2): - Flutter - Toast @@ -37,6 +39,7 @@ PODS: DEPENDENCIES: - Flutter (from `Flutter`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) + - flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -60,6 +63,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_udid: :path: ".symlinks/plugins/flutter_udid/ios" + flutter_web_auth: + :path: ".symlinks/plugins/flutter_web_auth/ios" fluttertoast: :path: ".symlinks/plugins/fluttertoast/ios" image_picker_ios: @@ -86,6 +91,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c + flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb diff --git a/mobile/lib/modules/login/models/hive_saved_login_info.model.dart b/mobile/lib/modules/login/models/hive_saved_login_info.model.dart index 6d367d5978..e807fc4780 100644 --- a/mobile/lib/modules/login/models/hive_saved_login_info.model.dart +++ b/mobile/lib/modules/login/models/hive_saved_login_info.model.dart @@ -5,21 +5,25 @@ part 'hive_saved_login_info.model.g.dart'; @HiveType(typeId: 0) class HiveSavedLoginInfo { @HiveField(0) - String email; + String email; // DEPRECATED @HiveField(1) - String password; + String password; // DEPRECATED @HiveField(2) String serverUrl; - @HiveField(3) + @HiveField(3, defaultValue: false) bool isSaveLogin; + @HiveField(4, defaultValue: "") + String accessToken; + HiveSavedLoginInfo({ required this.email, required this.password, required this.serverUrl, required this.isSaveLogin, + required this.accessToken, }); } diff --git a/mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart b/mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart index 80e6f30a9d..27c1d19672 100644 --- a/mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart +++ b/mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart @@ -20,14 +20,15 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter { email: fields[0] as String, password: fields[1] as String, serverUrl: fields[2] as String, - isSaveLogin: fields[3] as bool, + isSaveLogin: fields[3] == null ? false : fields[3] as bool, + accessToken: fields[4] == null ? '' : fields[4] as String, ); } @override void write(BinaryWriter writer, HiveSavedLoginInfo obj) { writer - ..writeByte(4) + ..writeByte(5) ..writeByte(0) ..write(obj.email) ..writeByte(1) @@ -35,7 +36,9 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter { ..writeByte(2) ..write(obj.serverUrl) ..writeByte(3) - ..write(obj.isSaveLogin); + ..write(obj.isSaveLogin) + ..writeByte(4) + ..write(obj.accessToken); } @override diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index f75fe3b544..89202f838a 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -74,15 +74,6 @@ class AuthenticationNotifier extends StateNotifier { return false; } - // Store device id to local storage - var deviceInfo = await _deviceInfoService.getDeviceInfo(); - Hive.box(userInfoBox).put(deviceIdKey, deviceInfo["deviceId"]); - - state = state.copyWith( - deviceId: deviceInfo["deviceId"], - deviceType: deviceInfo["deviceType"], - ); - // Make sign-in request try { var loginResponse = await _apiService.authenticationApi.login( @@ -97,65 +88,15 @@ class AuthenticationNotifier extends StateNotifier { return false; } - Hive.box(userInfoBox).put(accessTokenKey, loginResponse.accessToken); - - state = state.copyWith( - isAuthenticated: true, - userId: loginResponse.userId, - userEmail: loginResponse.userEmail, - firstName: loginResponse.firstName, - lastName: loginResponse.lastName, - profileImagePath: loginResponse.profileImagePath, - isAdmin: loginResponse.isAdmin, - shouldChangePassword: loginResponse.shouldChangePassword, + return setSuccessLoginInfo( + accessToken: loginResponse.accessToken, + isSavedLoginInfo: isSavedLoginInfo, ); - - // Login Success - Set Access Token to API Client - _apiService.setAccessToken(loginResponse.accessToken); - - if (isSavedLoginInfo) { - // Save login info to local storage - Hive.box(hiveLoginInfoBox).put( - savedLoginInfoKey, - HiveSavedLoginInfo( - email: email, - password: password, - isSaveLogin: true, - serverUrl: Hive.box(userInfoBox).get(serverEndpointKey), - ), - ); - } else { - Hive.box(hiveLoginInfoBox) - .delete(savedLoginInfoKey); - } } catch (e) { HapticFeedback.vibrate(); debugPrint("Error logging in $e"); return false; } - - // Register device info - try { - DeviceInfoResponseDto? deviceInfo = - await _apiService.deviceInfoApi.createDeviceInfo( - CreateDeviceInfoDto( - deviceId: state.deviceId, - deviceType: state.deviceType, - ), - ); - - if (deviceInfo == null) { - debugPrint('Device Info Response is null'); - return false; - } - - state = state.copyWith(deviceInfo: deviceInfo); - } catch (e) { - debugPrint("ERROR Register Device Info: $e"); - return false; - } - - return true; } Future logout() async { @@ -215,6 +156,74 @@ class AuthenticationNotifier extends StateNotifier { return false; } } + + Future setSuccessLoginInfo({ + required String accessToken, + required bool isSavedLoginInfo, + }) async { + Hive.box(userInfoBox).put(accessTokenKey, accessToken); + + _apiService.setAccessToken(accessToken); + var userResponseDto = await _apiService.userApi.getMyUserInfo(); + + if (userResponseDto != null) { + var deviceInfo = await _deviceInfoService.getDeviceInfo(); + Hive.box(userInfoBox).put(deviceIdKey, deviceInfo["deviceId"]); + + state = state.copyWith( + isAuthenticated: true, + userId: userResponseDto.id, + userEmail: userResponseDto.email, + firstName: userResponseDto.firstName, + lastName: userResponseDto.lastName, + profileImagePath: userResponseDto.profileImagePath, + isAdmin: userResponseDto.isAdmin, + shouldChangePassword: userResponseDto.shouldChangePassword, + deviceId: deviceInfo["deviceId"], + deviceType: deviceInfo["deviceType"], + ); + + if (isSavedLoginInfo) { + // Save login info to local storage + Hive.box(hiveLoginInfoBox).put( + savedLoginInfoKey, + HiveSavedLoginInfo( + email: "", + password: "", + isSaveLogin: true, + serverUrl: Hive.box(userInfoBox).get(serverEndpointKey), + accessToken: accessToken, + ), + ); + } else { + Hive.box(hiveLoginInfoBox) + .delete(savedLoginInfoKey); + } + } + + // Register device info + try { + DeviceInfoResponseDto? deviceInfo = + await _apiService.deviceInfoApi.createDeviceInfo( + CreateDeviceInfoDto( + deviceId: state.deviceId, + deviceType: state.deviceType, + ), + ); + + if (deviceInfo == null) { + debugPrint('Device Info Response is null'); + return false; + } + + state = state.copyWith(deviceInfo: deviceInfo); + } catch (e) { + debugPrint("ERROR Register Device Info: $e"); + return false; + } + + return true; + } } final authenticationProvider = diff --git a/mobile/lib/modules/login/providers/oauth.provider.dart b/mobile/lib/modules/login/providers/oauth.provider.dart new file mode 100644 index 0000000000..0470d539a5 --- /dev/null +++ b/mobile/lib/modules/login/providers/oauth.provider.dart @@ -0,0 +1,6 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/login/services/oauth.service.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; + +final OAuthServiceProvider = + Provider((ref) => OAuthService(ref.watch(apiServiceProvider))); diff --git a/mobile/lib/modules/login/services/oauth.service.dart b/mobile/lib/modules/login/services/oauth.service.dart new file mode 100644 index 0000000000..995aef2757 --- /dev/null +++ b/mobile/lib/modules/login/services/oauth.service.dart @@ -0,0 +1,39 @@ +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:openapi/api.dart'; +import 'package:flutter_web_auth/flutter_web_auth.dart'; + +// Redirect URL = app.immich:// + +class OAuthService { + final ApiService _apiService; + final callbackUrlScheme = 'app.immich'; + + OAuthService(this._apiService); + + Future getOAuthServerConfig( + String serverEndpoint, + ) async { + _apiService.setEndpoint(serverEndpoint); + + return await _apiService.oAuthApi.generateConfig( + OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'), + ); + } + + Future oAuthLogin(String oauthUrl) async { + try { + var result = await FlutterWebAuth.authenticate( + url: oauthUrl, + callbackUrlScheme: callbackUrlScheme, + ); + + return await _apiService.oAuthApi.callback( + OAuthCallbackDto( + url: result, + ), + ); + } catch (e) { + return null; + } + } +} diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index ea741faf1e..82f723f01e 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -6,11 +6,14 @@ import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; +import 'package:immich_mobile/modules/login/providers/oauth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:openapi/api.dart'; class LoginForm extends HookConsumerWidget { const LoginForm({Key? key}) : super(key: key); @@ -23,10 +26,47 @@ class LoginForm extends HookConsumerWidget { useTextEditingController.fromValue(TextEditingValue.empty); final serverEndpointController = useTextEditingController(text: 'login_form_endpoint_hint'.tr()); + final apiService = ref.watch(apiServiceProvider); + final serverEndpointFocusNode = useFocusNode(); final isSaveLoginInfo = useState(false); + final isLoading = useState(false); + final isOauthEnable = useState(false); + final oAuthButtonLabel = useState('OAuth'); + + getServeLoginConfig() async { + if (!serverEndpointFocusNode.hasFocus) { + var urlText = serverEndpointController.text.trim(); + + try { + var endpointUrl = Uri.tryParse(urlText); + + if (endpointUrl != null) { + isLoading.value = true; + apiService.setEndpoint(endpointUrl.toString()); + var loginConfig = await apiService.oAuthApi.generateConfig( + OAuthConfigDto(redirectUri: endpointUrl.toString()), + ); + + if (loginConfig != null) { + isOauthEnable.value = loginConfig.enabled; + oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth'; + } else { + isOauthEnable.value = false; + } + + isLoading.value = false; + } + } catch (_) { + isLoading.value = false; + isOauthEnable.value = false; + } + } + } useEffect( () { + serverEndpointFocusNode.addListener(getServeLoginConfig); + var loginInfo = Hive.box(hiveLoginInfoBox) .get(savedLoginInfoKey); @@ -37,6 +77,7 @@ class LoginForm extends HookConsumerWidget { isSaveLoginInfo.value = loginInfo.isSaveLogin; } + getServeLoginConfig(); return null; }, [], @@ -67,7 +108,10 @@ class LoginForm extends HookConsumerWidget { ), EmailInput(controller: usernameController), PasswordInput(controller: passwordController), - ServerEndpointInput(controller: serverEndpointController), + ServerEndpointInput( + controller: serverEndpointController, + focusNode: serverEndpointFocusNode, + ), CheckboxListTile( activeColor: Theme.of(context).primaryColor, contentPadding: const EdgeInsets.symmetric(horizontal: 8), @@ -92,12 +136,52 @@ class LoginForm extends HookConsumerWidget { } }, ), - LoginButton( - emailController: usernameController, - passwordController: passwordController, - serverEndpointController: serverEndpointController, - isSavedLoginInfo: isSaveLoginInfo.value, - ), + if (isLoading.value) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + if (!isLoading.value) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LoginButton( + emailController: usernameController, + passwordController: passwordController, + serverEndpointController: serverEndpointController, + isSavedLoginInfo: isSaveLoginInfo.value, + ), + 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, + isSavedLoginInfo: isSaveLoginInfo.value, + buttonLabel: oAuthButtonLabel.value, + isLoading: isLoading, + onLoginSuccess: () { + isLoading.value = false; + ref.watch(backupProvider.notifier).resumeBackup(); + AutoRouter.of(context).replace( + const TabControllerRoute(), + ); + }, + ), + ], + ], + ) ], ), ), @@ -108,9 +192,12 @@ class LoginForm extends HookConsumerWidget { class ServerEndpointInput extends StatelessWidget { final TextEditingController controller; - - const ServerEndpointInput({Key? key, required this.controller}) - : super(key: key); + final FocusNode focusNode; + const ServerEndpointInput({ + Key? key, + required this.controller, + required this.focusNode, + }) : super(key: key); String? _validateInput(String? url) { if (url?.startsWith(RegExp(r'https?://')) == true) { @@ -131,6 +218,7 @@ class ServerEndpointInput extends StatelessWidget { ), validator: _validateInput, autovalidateMode: AutovalidateMode.always, + focusNode: focusNode, ); } } @@ -200,13 +288,9 @@ class LoginButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return ElevatedButton( + return ElevatedButton.icon( style: ElevatedButton.styleFrom( - visualDensity: VisualDensity.standard, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.grey[50], - elevation: 2, - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25), + padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: () async { // This will remove current cache asset state of previous user login. @@ -238,10 +322,101 @@ class LoginButton extends ConsumerWidget { ); } }, - child: const Text( + 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 bool isSavedLoginInfo; + final ValueNotifier isLoading; + final VoidCallback onLoginSuccess; + final String buttonLabel; + + const OAuthLoginButton({ + Key? key, + required this.serverEndpointController, + required this.isSavedLoginInfo, + 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 { + 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, + isSavedLoginInfo: isSavedLoginInfo, + ); + + 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, + icon: const Icon(Icons.pin_rounded), + label: Text( + buttonLabel, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ); + } +} diff --git a/mobile/lib/shared/services/api.service.dart b/mobile/lib/shared/services/api.service.dart index c1b70a0e81..900e261e3a 100644 --- a/mobile/lib/shared/services/api.service.dart +++ b/mobile/lib/shared/services/api.service.dart @@ -5,6 +5,7 @@ class ApiService { late UserApi userApi; late AuthenticationApi authenticationApi; + late OAuthApi oAuthApi; late AlbumApi albumApi; late AssetApi assetApi; late ServerInfoApi serverInfoApi; @@ -14,6 +15,7 @@ class ApiService { _apiClient = ApiClient(basePath: endpoint); userApi = UserApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient); + oAuthApi = OAuthApi(_apiClient); albumApi = AlbumApi(_apiClient); assetApi = AssetApi(_apiClient); serverInfoApi = ServerInfoApi(_apiClient); diff --git a/mobile/lib/shared/ui/immich_toast.dart b/mobile/lib/shared/ui/immich_toast.dart index 80cac0ce96..1bc0bb4ea8 100644 --- a/mobile/lib/shared/ui/immich_toast.dart +++ b/mobile/lib/shared/ui/immich_toast.dart @@ -9,6 +9,7 @@ class ImmichToast { required String msg, ToastType toastType = ToastType.info, ToastGravity gravity = ToastGravity.TOP, + int durationInSecond = 3, }) { final isDarkTheme = Theme.of(context).brightness == Brightness.dark; final fToast = FToast(); @@ -77,7 +78,7 @@ class ImmichToast { ), ), gravity: gravity, - toastDuration: const Duration(seconds: 2), + toastDuration: Duration(seconds: durationInSecond), ); } } diff --git a/mobile/lib/shared/views/splash_screen.dart b/mobile/lib/shared/views/splash_screen.dart index ead677582a..b62e5d6b09 100644 --- a/mobile/lib/shared/views/splash_screen.dart +++ b/mobile/lib/shared/views/splash_screen.dart @@ -8,30 +8,34 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; class SplashScreenPage extends HookConsumerWidget { const SplashScreenPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + final apiService = ref.watch(apiServiceProvider); HiveSavedLoginInfo? loginInfo = Hive.box(hiveLoginInfoBox).get(savedLoginInfoKey); void performLoggingIn() async { - var isAuthenticated = - await ref.read(authenticationProvider.notifier).login( - loginInfo!.email, - loginInfo.password, - loginInfo.serverUrl, - true, - ); + if (loginInfo != null) { + // Make sure API service is initialized + apiService.setEndpoint(loginInfo.serverUrl); - if (isAuthenticated) { - // Resume backup (if enable) then navigate - ref.watch(backupProvider.notifier).resumeBackup(); - AutoRouter.of(context).replace(const TabControllerRoute()); - } else { - AutoRouter.of(context).replace(const LoginRoute()); + var isSuccess = + await ref.read(authenticationProvider.notifier).setSuccessLoginInfo( + accessToken: loginInfo.accessToken, + isSavedLoginInfo: true, + ); + if (isSuccess) { + // Resume backup (if enable) then navigate + ref.watch(backupProvider.notifier).resumeBackup(); + AutoRouter.of(context).replace(const TabControllerRoute()); + } else { + AutoRouter.of(context).replace(const LoginRoute()); + } } } diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index a6242d2c84..63c176378f 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -43,43 +43,46 @@ class UserResponseDto { DateTime? deletedAt; @override - bool operator ==(Object other) => identical(this, other) || other is UserResponseDto && - other.id == id && - other.email == email && - other.firstName == firstName && - other.lastName == lastName && - other.createdAt == createdAt && - other.profileImagePath == profileImagePath && - other.shouldChangePassword == shouldChangePassword && - other.isAdmin == isAdmin && - other.deletedAt == deletedAt; + bool operator ==(Object other) => + identical(this, other) || + other is UserResponseDto && + other.id == id && + other.email == email && + other.firstName == firstName && + other.lastName == lastName && + other.createdAt == createdAt && + other.profileImagePath == profileImagePath && + other.shouldChangePassword == shouldChangePassword && + other.isAdmin == isAdmin && + other.deletedAt == deletedAt; @override int get hashCode => - // ignore: unnecessary_parenthesis - (id.hashCode) + - (email.hashCode) + - (firstName.hashCode) + - (lastName.hashCode) + - (createdAt.hashCode) + - (profileImagePath.hashCode) + - (shouldChangePassword.hashCode) + - (isAdmin.hashCode) + - (deletedAt == null ? 0 : deletedAt!.hashCode); + // ignore: unnecessary_parenthesis + (id.hashCode) + + (email.hashCode) + + (firstName.hashCode) + + (lastName.hashCode) + + (createdAt.hashCode) + + (profileImagePath.hashCode) + + (shouldChangePassword.hashCode) + + (isAdmin.hashCode) + + (deletedAt == null ? 0 : deletedAt!.hashCode); @override - String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]'; + String toString() => + 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt]'; Map toJson() { final _json = {}; - _json[r'id'] = id; - _json[r'email'] = email; - _json[r'firstName'] = firstName; - _json[r'lastName'] = lastName; - _json[r'createdAt'] = createdAt; - _json[r'profileImagePath'] = profileImagePath; - _json[r'shouldChangePassword'] = shouldChangePassword; - _json[r'isAdmin'] = isAdmin; + _json[r'id'] = id; + _json[r'email'] = email; + _json[r'firstName'] = firstName; + _json[r'lastName'] = lastName; + _json[r'createdAt'] = createdAt; + _json[r'profileImagePath'] = profileImagePath; + _json[r'shouldChangePassword'] = shouldChangePassword; + _json[r'isAdmin'] = isAdmin; if (deletedAt != null) { _json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String(); } else { @@ -98,13 +101,13 @@ class UserResponseDto { // Ensure that the map contains the required keys. // Note 1: the values aren't checked for validity beyond being non-null. // Note 2: this code is stripped in release mode! - assert(() { - requiredKeys.forEach((key) { - assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.'); - assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.'); - }); - return true; - }()); + // assert(() { + // requiredKeys.forEach((key) { + // assert(json.containsKey(key), 'Required key "UserResponseDto[$key]" is missing from JSON.'); + // assert(json[key] != null, 'Required key "UserResponseDto[$key]" has a null value in JSON.'); + // }); + // return true; + // }()); return UserResponseDto( id: mapValueOfType(json, r'id')!, @@ -113,7 +116,8 @@ class UserResponseDto { lastName: mapValueOfType(json, r'lastName')!, createdAt: mapValueOfType(json, r'createdAt')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, - shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, + shouldChangePassword: + mapValueOfType(json, r'shouldChangePassword')!, isAdmin: mapValueOfType(json, r'isAdmin')!, deletedAt: mapDateTime(json, r'deletedAt', ''), ); @@ -121,7 +125,10 @@ class UserResponseDto { return null; } - static List? listFromJson(dynamic json, {bool growable = false,}) { + static List? listFromJson( + dynamic json, { + bool growable = false, + }) { final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { @@ -149,12 +156,18 @@ class UserResponseDto { } // maps a json object with a list of UserResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + static Map> mapListFromJson( + dynamic json, { + bool growable = false, + }) { final map = >{}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = UserResponseDto.listFromJson(entry.value, growable: growable,); + final value = UserResponseDto.listFromJson( + entry.value, + growable: growable, + ); if (value != null) { map[entry.key] = value; } @@ -176,4 +189,3 @@ class UserResponseDto { 'deletedAt', }; } - diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 8dcc4e87a9..cbbf11d432 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -366,6 +366,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + flutter_web_auth: + dependency: "direct main" + description: + name: flutter_web_auth + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.0" flutter_web_plugins: dependency: transitive description: flutter diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3e24dbf8f5..1949ce7145 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: latlong2: ^0.8.1 collection: ^1.16.0 http_parser: ^4.0.1 + flutter_web_auth: ^0.5.0 openapi: path: openapi diff --git a/server/apps/immich/src/api-v1/oauth/oauth.controller.ts b/server/apps/immich/src/api-v1/oauth/oauth.controller.ts index eb864a1cb3..13637acc0c 100644 --- a/server/apps/immich/src/api-v1/oauth/oauth.controller.ts +++ b/server/apps/immich/src/api-v1/oauth/oauth.controller.ts @@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { AuthType } from '../../constants/jwt.constant'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; +import { LoginResponseDto } from '../auth/response-dto/login-response.dto'; import { OAuthCallbackDto } from './dto/oauth-auth-code.dto'; import { OAuthConfigDto } from './dto/oauth-config.dto'; import { OAuthService } from './oauth.service'; @@ -19,7 +20,10 @@ export class OAuthController { } @Post('/callback') - public async callback(@Res({ passthrough: true }) response: Response, @Body(ValidationPipe) dto: OAuthCallbackDto) { + public async callback( + @Res({ passthrough: true }) response: Response, + @Body(ValidationPipe) dto: OAuthCallbackDto, + ): Promise { const loginResponse = await this.oauthService.callback(dto); response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH)); return loginResponse;