Browse Source

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
Alex 2 năm trước cách đây
mục cha
commit
b3e51cc849

BIN
docs/docs/usage/img/authentik-redirect.png


+ 10 - 2
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**
+<img src={require('./img/authentik-redirect.png').default} title="Authentik Redirection URL" width="80%" />
+:::
 
 ## Enable OAuth
 

+ 1 - 1
mobile/android/app/build.gradle

@@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
 
 
 android {
-    compileSdkVersion flutter.compileSdkVersion
+    compileSdkVersion 33
 
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_1_8

+ 15 - 4
mobile/android/app/src/main/AndroidManifest.xml

@@ -12,15 +12,26 @@
       </intent-filter>
 
     </activity>
+
+
+    <activity
+      android:name="com.linusu.flutter_web_auth.CallbackActivity"
+      android:exported="true">
+      <intent-filter android:label="flutter_web_auth">
+        <action android:name="android.intent.action.VIEW" />
+        <category android:name="android.intent.category.DEFAULT" />
+        <category android:name="android.intent.category.BROWSABLE" />
+        <data android:scheme="app.immich" />
+      </intent-filter>
+    </activity>
     <!-- Don't delete the meta-data below.
              This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
     <meta-data android:name="flutterEmbedding" android:value="2" />
     <!-- Disables default WorkManager initialization to use our custom initialization -->
     <provider
-        android:name="androidx.startup.InitializationProvider"
-        android:authorities="${applicationId}.androidx-startup"
-        tools:node="remove">
-    </provider>
+      android:name="androidx.startup.InitializationProvider"
+      android:authorities="${applicationId}.androidx-startup"
+      tools:node="remove"></provider>
   </application>
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

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

+ 6 - 0
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

+ 7 - 3
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,
   });
 }

+ 6 - 3
mobile/lib/modules/login/models/hive_saved_login_info.model.g.dart

@@ -20,14 +20,15 @@ class HiveSavedLoginInfoAdapter extends TypeAdapter<HiveSavedLoginInfo> {
       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<HiveSavedLoginInfo> {
       ..writeByte(2)
       ..write(obj.serverUrl)
       ..writeByte(3)
-      ..write(obj.isSaveLogin);
+      ..write(obj.isSaveLogin)
+      ..writeByte(4)
+      ..write(obj.accessToken);
   }
 
   @override

+ 71 - 62
mobile/lib/modules/login/providers/authentication.provider.dart

@@ -74,15 +74,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       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<AuthenticationState> {
         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<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
-          savedLoginInfoKey,
-          HiveSavedLoginInfo(
-            email: email,
-            password: password,
-            isSaveLogin: true,
-            serverUrl: Hive.box(userInfoBox).get(serverEndpointKey),
-          ),
-        );
-      } else {
-        Hive.box<HiveSavedLoginInfo>(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<bool> logout() async {
@@ -215,6 +156,74 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       return false;
     }
   }
+
+  Future<bool> 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<HiveSavedLoginInfo>(hiveLoginInfoBox).put(
+          savedLoginInfoKey,
+          HiveSavedLoginInfo(
+            email: "",
+            password: "",
+            isSaveLogin: true,
+            serverUrl: Hive.box(userInfoBox).get(serverEndpointKey),
+            accessToken: accessToken,
+          ),
+        );
+      } else {
+        Hive.box<HiveSavedLoginInfo>(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 =

+ 6 - 0
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)));

+ 39 - 0
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<OAuthConfigResponseDto?> getOAuthServerConfig(
+    String serverEndpoint,
+  ) async {
+    _apiService.setEndpoint(serverEndpoint);
+
+    return await _apiService.oAuthApi.generateConfig(
+      OAuthConfigDto(redirectUri: '$callbackUrlScheme:/'),
+    );
+  }
+
+  Future<LoginResponseDto?> 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;
+    }
+  }
+}

+ 192 - 17
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<bool>(false);
+    final isLoading = useState<bool>(false);
+    final isOauthEnable = useState<bool>(false);
+    final oAuthButtonLabel = useState<String>('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<HiveSavedLoginInfo>(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<bool> 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),
+      ),
+    );
+  }
+}

+ 2 - 0
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);

+ 2 - 1
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),
     );
   }
 }

+ 17 - 13
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<HiveSavedLoginInfo>(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());
+        }
       }
     }
 

+ 53 - 41
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<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
-      _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<String>(json, r'id')!,
@@ -113,7 +116,8 @@ class UserResponseDto {
         lastName: mapValueOfType<String>(json, r'lastName')!,
         createdAt: mapValueOfType<String>(json, r'createdAt')!,
         profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
-        shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
+        shouldChangePassword:
+            mapValueOfType<bool>(json, r'shouldChangePassword')!,
         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
         deletedAt: mapDateTime(json, r'deletedAt', ''),
       );
@@ -121,7 +125,10 @@ class UserResponseDto {
     return null;
   }
 
-  static List<UserResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+  static List<UserResponseDto>? listFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final result = <UserResponseDto>[];
     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<String, List<UserResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+  static Map<String, List<UserResponseDto>> mapListFromJson(
+    dynamic json, {
+    bool growable = false,
+  }) {
     final map = <String, List<UserResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // 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',
   };
 }
-

+ 7 - 0
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

+ 1 - 0
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

+ 5 - 1
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<LoginResponseDto> {
     const loginResponse = await this.oauthService.callback(dto);
     response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
     return loginResponse;