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;