diff --git a/docs/docs/usage/img/authentik-redirect.png b/docs/docs/usage/img/authentik-redirect.png new file mode 100644 index 0000000000..c16c03bab5 Binary files /dev/null and b/docs/docs/usage/img/authentik-redirect.png differ 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;