mirror of
https://github.com/immich-app/immich.git
synced 2025-01-17 01:06:46 +01:00
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
This commit is contained in:
parent
e01e4e6530
commit
b3e51cc849
19 changed files with 384 additions and 105 deletions
BIN
docs/docs/usage/img/authentik-redirect.png
Normal file
BIN
docs/docs/usage/img/authentik-redirect.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 51 KiB |
|
@ -28,9 +28,17 @@ Before enabling OAuth in Immich, a new client application needs to be configured
|
||||||
|
|
||||||
2. Configure Redirect URIs/Origins
|
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
|
## Enable OAuth
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ if (keystorePropertiesFile.exists()) {
|
||||||
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion flutter.compileSdkVersion
|
compileSdkVersion 33
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
|
|
@ -12,15 +12,26 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data android:name="flutterEmbedding" android:value="2" />
|
<meta-data android:name="flutterEmbedding" android:value="2" />
|
||||||
<!-- Disables default WorkManager initialization to use our custom initialization -->
|
<!-- Disables default WorkManager initialization to use our custom initialization -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.startup.InitializationProvider"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.androidx-startup"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
tools:node="remove">
|
tools:node="remove"></provider>
|
||||||
</provider>
|
|
||||||
</application>
|
</application>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
|
@ -109,7 +109,9 @@
|
||||||
"login_form_err_invalid_email": "Invalid Email",
|
"login_form_err_invalid_email": "Invalid Email",
|
||||||
"login_form_err_leading_whitespace": "Leading whitespace",
|
"login_form_err_leading_whitespace": "Leading whitespace",
|
||||||
"login_form_err_trailing_whitespace": "Trailing 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_email": "Email",
|
||||||
"login_form_label_password": "Password",
|
"login_form_label_password": "Password",
|
||||||
"login_form_password_hint": "password",
|
"login_form_password_hint": "password",
|
||||||
|
|
|
@ -3,6 +3,8 @@ PODS:
|
||||||
- flutter_udid (0.0.1):
|
- flutter_udid (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
|
- flutter_web_auth (0.5.0):
|
||||||
|
- Flutter
|
||||||
- fluttertoast (0.0.2):
|
- fluttertoast (0.0.2):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Toast
|
- Toast
|
||||||
|
@ -37,6 +39,7 @@ PODS:
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||||
|
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
|
@ -60,6 +63,8 @@ EXTERNAL SOURCES:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_udid:
|
flutter_udid:
|
||||||
:path: ".symlinks/plugins/flutter_udid/ios"
|
:path: ".symlinks/plugins/flutter_udid/ios"
|
||||||
|
flutter_web_auth:
|
||||||
|
:path: ".symlinks/plugins/flutter_web_auth/ios"
|
||||||
fluttertoast:
|
fluttertoast:
|
||||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
|
@ -86,6 +91,7 @@ EXTERNAL SOURCES:
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||||
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
|
flutter_udid: 0848809dbed4c055175747ae6a45a8b4f6771e1c
|
||||||
|
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||||
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
fluttertoast: 16fbe6039d06a763f3533670197d01fc73459037
|
||||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||||
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
|
image_picker_ios: b786a5dcf033a8336a657191401bfdf12017dabb
|
||||||
|
|
|
@ -5,21 +5,25 @@ part 'hive_saved_login_info.model.g.dart';
|
||||||
@HiveType(typeId: 0)
|
@HiveType(typeId: 0)
|
||||||
class HiveSavedLoginInfo {
|
class HiveSavedLoginInfo {
|
||||||
@HiveField(0)
|
@HiveField(0)
|
||||||
String email;
|
String email; // DEPRECATED
|
||||||
|
|
||||||
@HiveField(1)
|
@HiveField(1)
|
||||||
String password;
|
String password; // DEPRECATED
|
||||||
|
|
||||||
@HiveField(2)
|
@HiveField(2)
|
||||||
String serverUrl;
|
String serverUrl;
|
||||||
|
|
||||||
@HiveField(3)
|
@HiveField(3, defaultValue: false)
|
||||||
bool isSaveLogin;
|
bool isSaveLogin;
|
||||||
|
|
||||||
|
@HiveField(4, defaultValue: "")
|
||||||
|
String accessToken;
|
||||||
|
|
||||||
HiveSavedLoginInfo({
|
HiveSavedLoginInfo({
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.serverUrl,
|
required this.serverUrl,
|
||||||
required this.isSaveLogin,
|
required this.isSaveLogin,
|
||||||
|
required this.accessToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
|
@ -74,15 +74,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||||
return false;
|
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
|
// Make sign-in request
|
||||||
try {
|
try {
|
||||||
var loginResponse = await _apiService.authenticationApi.login(
|
var loginResponse = await _apiService.authenticationApi.login(
|
||||||
|
@ -97,65 +88,15 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Hive.box(userInfoBox).put(accessTokenKey, loginResponse.accessToken);
|
return setSuccessLoginInfo(
|
||||||
|
accessToken: loginResponse.accessToken,
|
||||||
state = state.copyWith(
|
isSavedLoginInfo: isSavedLoginInfo,
|
||||||
isAuthenticated: true,
|
|
||||||
userId: loginResponse.userId,
|
|
||||||
userEmail: loginResponse.userEmail,
|
|
||||||
firstName: loginResponse.firstName,
|
|
||||||
lastName: loginResponse.lastName,
|
|
||||||
profileImagePath: loginResponse.profileImagePath,
|
|
||||||
isAdmin: loginResponse.isAdmin,
|
|
||||||
shouldChangePassword: loginResponse.shouldChangePassword,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) {
|
} catch (e) {
|
||||||
HapticFeedback.vibrate();
|
HapticFeedback.vibrate();
|
||||||
debugPrint("Error logging in $e");
|
debugPrint("Error logging in $e");
|
||||||
return false;
|
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 {
|
Future<bool> logout() async {
|
||||||
|
@ -215,6 +156,74 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||||
return false;
|
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 =
|
final authenticationProvider =
|
||||||
|
|
6
mobile/lib/modules/login/providers/oauth.provider.dart
Normal file
6
mobile/lib/modules/login/providers/oauth.provider.dart
Normal file
|
@ -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
mobile/lib/modules/login/services/oauth.service.dart
Normal file
39
mobile/lib/modules/login/services/oauth.service.dart
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,11 +6,14 @@ import 'package:hive/hive.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/hive_box.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/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/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/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.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/modules/backup/providers/backup.provider.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
|
||||||
class LoginForm extends HookConsumerWidget {
|
class LoginForm extends HookConsumerWidget {
|
||||||
const LoginForm({Key? key}) : super(key: key);
|
const LoginForm({Key? key}) : super(key: key);
|
||||||
|
@ -23,10 +26,47 @@ class LoginForm extends HookConsumerWidget {
|
||||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||||
final serverEndpointController =
|
final serverEndpointController =
|
||||||
useTextEditingController(text: 'login_form_endpoint_hint'.tr());
|
useTextEditingController(text: 'login_form_endpoint_hint'.tr());
|
||||||
|
final apiService = ref.watch(apiServiceProvider);
|
||||||
|
final serverEndpointFocusNode = useFocusNode();
|
||||||
final isSaveLoginInfo = useState<bool>(false);
|
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(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
|
serverEndpointFocusNode.addListener(getServeLoginConfig);
|
||||||
|
|
||||||
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
|
var loginInfo = Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox)
|
||||||
.get(savedLoginInfoKey);
|
.get(savedLoginInfoKey);
|
||||||
|
|
||||||
|
@ -37,6 +77,7 @@ class LoginForm extends HookConsumerWidget {
|
||||||
isSaveLoginInfo.value = loginInfo.isSaveLogin;
|
isSaveLoginInfo.value = loginInfo.isSaveLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getServeLoginConfig();
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
|
@ -67,7 +108,10 @@ class LoginForm extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
EmailInput(controller: usernameController),
|
EmailInput(controller: usernameController),
|
||||||
PasswordInput(controller: passwordController),
|
PasswordInput(controller: passwordController),
|
||||||
ServerEndpointInput(controller: serverEndpointController),
|
ServerEndpointInput(
|
||||||
|
controller: serverEndpointController,
|
||||||
|
focusNode: serverEndpointFocusNode,
|
||||||
|
),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
activeColor: Theme.of(context).primaryColor,
|
activeColor: Theme.of(context).primaryColor,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
@ -92,12 +136,52 @@ class LoginForm extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
LoginButton(
|
if (isLoading.value)
|
||||||
emailController: usernameController,
|
const SizedBox(
|
||||||
passwordController: passwordController,
|
width: 24,
|
||||||
serverEndpointController: serverEndpointController,
|
height: 24,
|
||||||
isSavedLoginInfo: isSaveLoginInfo.value,
|
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 {
|
class ServerEndpointInput extends StatelessWidget {
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
|
final FocusNode focusNode;
|
||||||
const ServerEndpointInput({Key? key, required this.controller})
|
const ServerEndpointInput({
|
||||||
: super(key: key);
|
Key? key,
|
||||||
|
required this.controller,
|
||||||
|
required this.focusNode,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
String? _validateInput(String? url) {
|
String? _validateInput(String? url) {
|
||||||
if (url?.startsWith(RegExp(r'https?://')) == true) {
|
if (url?.startsWith(RegExp(r'https?://')) == true) {
|
||||||
|
@ -131,6 +218,7 @@ class ServerEndpointInput extends StatelessWidget {
|
||||||
),
|
),
|
||||||
validator: _validateInput,
|
validator: _validateInput,
|
||||||
autovalidateMode: AutovalidateMode.always,
|
autovalidateMode: AutovalidateMode.always,
|
||||||
|
focusNode: focusNode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -200,13 +288,9 @@ class LoginButton extends ConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return ElevatedButton(
|
return ElevatedButton.icon(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
visualDensity: VisualDensity.standard,
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
backgroundColor: Theme.of(context).primaryColor,
|
|
||||||
foregroundColor: Colors.grey[50],
|
|
||||||
elevation: 2,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
|
||||||
),
|
),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
// This will remove current cache asset state of previous user login.
|
// 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",
|
"login_form_button_text",
|
||||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||||
).tr(),
|
).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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ class ApiService {
|
||||||
|
|
||||||
late UserApi userApi;
|
late UserApi userApi;
|
||||||
late AuthenticationApi authenticationApi;
|
late AuthenticationApi authenticationApi;
|
||||||
|
late OAuthApi oAuthApi;
|
||||||
late AlbumApi albumApi;
|
late AlbumApi albumApi;
|
||||||
late AssetApi assetApi;
|
late AssetApi assetApi;
|
||||||
late ServerInfoApi serverInfoApi;
|
late ServerInfoApi serverInfoApi;
|
||||||
|
@ -14,6 +15,7 @@ class ApiService {
|
||||||
_apiClient = ApiClient(basePath: endpoint);
|
_apiClient = ApiClient(basePath: endpoint);
|
||||||
userApi = UserApi(_apiClient);
|
userApi = UserApi(_apiClient);
|
||||||
authenticationApi = AuthenticationApi(_apiClient);
|
authenticationApi = AuthenticationApi(_apiClient);
|
||||||
|
oAuthApi = OAuthApi(_apiClient);
|
||||||
albumApi = AlbumApi(_apiClient);
|
albumApi = AlbumApi(_apiClient);
|
||||||
assetApi = AssetApi(_apiClient);
|
assetApi = AssetApi(_apiClient);
|
||||||
serverInfoApi = ServerInfoApi(_apiClient);
|
serverInfoApi = ServerInfoApi(_apiClient);
|
||||||
|
|
|
@ -9,6 +9,7 @@ class ImmichToast {
|
||||||
required String msg,
|
required String msg,
|
||||||
ToastType toastType = ToastType.info,
|
ToastType toastType = ToastType.info,
|
||||||
ToastGravity gravity = ToastGravity.TOP,
|
ToastGravity gravity = ToastGravity.TOP,
|
||||||
|
int durationInSecond = 3,
|
||||||
}) {
|
}) {
|
||||||
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
final isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||||
final fToast = FToast();
|
final fToast = FToast();
|
||||||
|
@ -77,7 +78,7 @@ class ImmichToast {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
gravity: gravity,
|
gravity: gravity,
|
||||||
toastDuration: const Duration(seconds: 2),
|
toastDuration: Duration(seconds: durationInSecond),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||||
|
|
||||||
class SplashScreenPage extends HookConsumerWidget {
|
class SplashScreenPage extends HookConsumerWidget {
|
||||||
const SplashScreenPage({Key? key}) : super(key: key);
|
const SplashScreenPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final apiService = ref.watch(apiServiceProvider);
|
||||||
HiveSavedLoginInfo? loginInfo =
|
HiveSavedLoginInfo? loginInfo =
|
||||||
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
|
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).get(savedLoginInfoKey);
|
||||||
|
|
||||||
void performLoggingIn() async {
|
void performLoggingIn() async {
|
||||||
var isAuthenticated =
|
if (loginInfo != null) {
|
||||||
await ref.read(authenticationProvider.notifier).login(
|
// Make sure API service is initialized
|
||||||
loginInfo!.email,
|
apiService.setEndpoint(loginInfo.serverUrl);
|
||||||
loginInfo.password,
|
|
||||||
loginInfo.serverUrl,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isAuthenticated) {
|
var isSuccess =
|
||||||
// Resume backup (if enable) then navigate
|
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
|
||||||
ref.watch(backupProvider.notifier).resumeBackup();
|
accessToken: loginInfo.accessToken,
|
||||||
AutoRouter.of(context).replace(const TabControllerRoute());
|
isSavedLoginInfo: true,
|
||||||
} else {
|
);
|
||||||
AutoRouter.of(context).replace(const LoginRoute());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Binary file not shown.
|
@ -366,6 +366,13 @@ packages:
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
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:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
@ -40,6 +40,7 @@ dependencies:
|
||||||
latlong2: ^0.8.1
|
latlong2: ^0.8.1
|
||||||
collection: ^1.16.0
|
collection: ^1.16.0
|
||||||
http_parser: ^4.0.1
|
http_parser: ^4.0.1
|
||||||
|
flutter_web_auth: ^0.5.0
|
||||||
|
|
||||||
openapi:
|
openapi:
|
||||||
path: openapi
|
path: openapi
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { AuthType } from '../../constants/jwt.constant';
|
import { AuthType } from '../../constants/jwt.constant';
|
||||||
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
|
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 { OAuthCallbackDto } from './dto/oauth-auth-code.dto';
|
||||||
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
import { OAuthConfigDto } from './dto/oauth-config.dto';
|
||||||
import { OAuthService } from './oauth.service';
|
import { OAuthService } from './oauth.service';
|
||||||
|
@ -19,7 +20,10 @@ export class OAuthController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('/callback')
|
@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);
|
const loginResponse = await this.oauthService.callback(dto);
|
||||||
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
|
response.setHeader('Set-Cookie', this.immichJwtService.getCookies(loginResponse, AuthType.OAUTH));
|
||||||
return loginResponse;
|
return loginResponse;
|
||||||
|
|
Loading…
Reference in a new issue