Skip to content

Commit bc3a71a

Browse files
committed
refactor: login form
1 parent 62628df commit bc3a71a

File tree

11 files changed

+655
-457
lines changed

11 files changed

+655
-457
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class OAuthLoginData {
2+
final String serverUrl;
3+
final String state;
4+
final String codeVerifier;
5+
6+
const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
7+
}

mobile/lib/providers/auth.provider.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,35 @@ import 'package:immich_mobile/models/auth/auth_state.model.dart';
99
import 'package:immich_mobile/models/auth/login_response.model.dart';
1010
import 'package:immich_mobile/providers/api.provider.dart';
1111
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
12+
import 'package:immich_mobile/providers/server_info.provider.dart';
1213
import 'package:immich_mobile/services/api.service.dart';
1314
import 'package:immich_mobile/services/auth.service.dart';
1415
import 'package:immich_mobile/services/secure_storage.service.dart';
1516
import 'package:immich_mobile/services/upload.service.dart';
1617
import 'package:immich_mobile/services/widget.service.dart';
1718
import 'package:immich_mobile/utils/hash.dart';
19+
import 'package:immich_mobile/utils/url_helper.dart';
1820
import 'package:logging/logging.dart';
1921
import 'package:openapi/api.dart';
2022
import 'package:immich_mobile/utils/debug_print.dart';
2123

24+
class ServerAuthSettings {
25+
final String endpoint;
26+
final bool isOAuthEnabled;
27+
final bool isPasswordLoginEnabled;
28+
final String oAuthButtonText;
29+
30+
const ServerAuthSettings({
31+
required this.endpoint,
32+
required this.isOAuthEnabled,
33+
required this.isPasswordLoginEnabled,
34+
required this.oAuthButtonText,
35+
});
36+
}
37+
2238
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
2339
return AuthNotifier(
40+
ref,
2441
ref.watch(authServiceProvider),
2542
ref.watch(apiServiceProvider),
2643
ref.watch(userServiceProvider),
@@ -31,6 +48,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
3148
});
3249

3350
class AuthNotifier extends StateNotifier<AuthState> {
51+
final Ref _ref;
3452
final AuthService _authService;
3553
final ApiService _apiService;
3654
final UserService _userService;
@@ -42,6 +60,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
4260
static const Duration _timeoutDuration = Duration(seconds: 7);
4361

4462
AuthNotifier(
63+
this._ref,
4564
this._authService,
4665
this._apiService,
4766
this._userService,
@@ -64,6 +83,29 @@ class AuthNotifier extends StateNotifier<AuthState> {
6483
return _authService.validateServerUrl(url);
6584
}
6685

86+
/// Validates the server URL and fetches authentication settings.
87+
/// Returns [ServerAuthSettings] with OAuth/password login configuration.
88+
Future<ServerAuthSettings> getServerAuthSettings(String serverUrl) async {
89+
final sanitizedUrl = sanitizeUrl(serverUrl);
90+
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
91+
92+
final endpoint = await _authService.validateServerUrl(encodedUrl);
93+
94+
// Fetch and load server config and features
95+
await _ref.read(serverInfoProvider.notifier).getServerInfo();
96+
97+
final serverInfo = _ref.read(serverInfoProvider);
98+
final features = serverInfo.serverFeatures;
99+
final config = serverInfo.serverConfig;
100+
101+
return ServerAuthSettings(
102+
endpoint: endpoint,
103+
isOAuthEnabled: features.oauthEnabled,
104+
isPasswordLoginEnabled: features.passwordLogin,
105+
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
106+
);
107+
}
108+
67109
/// Validating the url is the alternative connecting server url without
68110
/// saving the information to the local database
69111
Future<bool> validateAuxilaryServerUrl(String url) async {
Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
import 'package:hooks_riverpod/hooks_riverpod.dart';
2-
import 'package:immich_mobile/services/oauth.service.dart';
2+
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
33
import 'package:immich_mobile/providers/api.provider.dart';
4+
import 'package:immich_mobile/services/oauth.service.dart';
5+
import 'package:openapi/api.dart';
6+
7+
export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
48

59
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
10+
11+
final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
12+
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
13+
);
14+
15+
class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
16+
final OAuthService _oAuthService;
17+
18+
OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));
19+
20+
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
21+
return _oAuthService.getOAuthLoginData(serverUrl);
22+
}
23+
24+
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
25+
return _oAuthService.completeOAuthLogin(oAuthData);
26+
}
27+
}

mobile/lib/services/oauth.service.dart

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import 'dart:convert';
2+
import 'dart:math';
3+
4+
import 'package:crypto/crypto.dart';
15
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
6+
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
27
import 'package:immich_mobile/services/api.service.dart';
8+
import 'package:immich_mobile/utils/url_helper.dart';
39
import 'package:logging/logging.dart';
410
import 'package:openapi/api.dart';
511

@@ -11,6 +17,50 @@ class OAuthService {
1117
final log = Logger('OAuthService');
1218
OAuthService(this._apiService);
1319

20+
String _generateRandomString(int length) {
21+
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
22+
final random = Random.secure();
23+
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
24+
}
25+
26+
List<int> _randomBytes(int length) {
27+
final random = Random.secure();
28+
return List<int>.generate(length, (i) => random.nextInt(256));
29+
}
30+
31+
/// Per specification, the code verifier must be 43-128 characters long
32+
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
33+
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
34+
String _randomCodeVerifier() {
35+
return base64Url.encode(_randomBytes(42));
36+
}
37+
38+
String _generatePKCECodeChallenge(String codeVerifier) {
39+
final bytes = utf8.encode(codeVerifier);
40+
final digest = sha256.convert(bytes);
41+
return base64Url.encode(digest.bytes).replaceAll('=', '');
42+
}
43+
44+
/// Initiates OAuth login flow.
45+
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
46+
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
47+
final state = _generateRandomString(32);
48+
final codeVerifier = _randomCodeVerifier();
49+
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);
50+
51+
final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);
52+
53+
if (oAuthServerUrl == null) {
54+
return null;
55+
}
56+
57+
return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
58+
}
59+
60+
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
61+
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
62+
}
63+
1464
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
1565
// Resolve API server endpoint from user provided serverUrl
1666
await _apiService.resolveAndSetEndpoint(serverUrl);

mobile/lib/widgets/forms/login/login_button.dart

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import 'package:easy_localization/easy_localization.dart';
22
import 'package:flutter/material.dart';
3-
import 'package:hooks_riverpod/hooks_riverpod.dart';
43

5-
class LoginButton extends ConsumerWidget {
6-
final Function() onPressed;
4+
class LoginButton extends StatelessWidget {
5+
final VoidCallback onPressed;
76

87
const LoginButton({super.key, required this.onPressed});
98

109
@override
11-
Widget build(BuildContext context, WidgetRef ref) {
10+
Widget build(BuildContext context) {
1211
return ElevatedButton.icon(
1312
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
1413
onPressed: onPressed,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import 'package:easy_localization/easy_localization.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:immich_mobile/extensions/build_context_extensions.dart';
4+
import 'package:immich_mobile/utils/url_helper.dart';
5+
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
6+
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
7+
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
8+
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
9+
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
10+
import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart';
11+
12+
class LoginCredentialsForm extends StatelessWidget {
13+
final TextEditingController emailController;
14+
final TextEditingController passwordController;
15+
final TextEditingController serverEndpointController;
16+
final FocusNode emailFocusNode;
17+
final FocusNode passwordFocusNode;
18+
final bool isLoading;
19+
final bool isOAuthEnabled;
20+
final bool isPasswordLoginEnabled;
21+
final String oAuthButtonLabel;
22+
final String? warningMessage;
23+
final VoidCallback onLogin;
24+
final VoidCallback onOAuthLogin;
25+
final VoidCallback onBack;
26+
27+
const LoginCredentialsForm({
28+
super.key,
29+
required this.emailController,
30+
required this.passwordController,
31+
required this.serverEndpointController,
32+
required this.emailFocusNode,
33+
required this.passwordFocusNode,
34+
required this.isLoading,
35+
required this.isOAuthEnabled,
36+
required this.isPasswordLoginEnabled,
37+
required this.oAuthButtonLabel,
38+
required this.warningMessage,
39+
required this.onLogin,
40+
required this.onOAuthLogin,
41+
required this.onBack,
42+
});
43+
44+
@override
45+
Widget build(BuildContext context) {
46+
return AutofillGroup(
47+
child: Column(
48+
crossAxisAlignment: CrossAxisAlignment.stretch,
49+
children: [
50+
if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!),
51+
Text(
52+
sanitizeUrl(serverEndpointController.text),
53+
style: context.textTheme.displaySmall,
54+
textAlign: TextAlign.center,
55+
),
56+
if (isPasswordLoginEnabled) ...[
57+
const SizedBox(height: 18),
58+
EmailInput(
59+
controller: emailController,
60+
focusNode: emailFocusNode,
61+
onSubmit: passwordFocusNode.requestFocus,
62+
),
63+
const SizedBox(height: 8),
64+
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin),
65+
],
66+
isLoading
67+
? const LoadingIcon()
68+
: Column(
69+
crossAxisAlignment: CrossAxisAlignment.stretch,
70+
mainAxisAlignment: MainAxisAlignment.center,
71+
children: [
72+
const SizedBox(height: 18),
73+
if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin),
74+
if (isOAuthEnabled) ...[
75+
if (isPasswordLoginEnabled)
76+
Padding(
77+
padding: const EdgeInsets.symmetric(horizontal: 16.0),
78+
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
79+
),
80+
OAuthLoginButton(
81+
serverEndpointController: serverEndpointController,
82+
buttonLabel: oAuthButtonLabel,
83+
onPressed: onOAuthLogin,
84+
),
85+
],
86+
],
87+
),
88+
if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()),
89+
const SizedBox(height: 12),
90+
TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()),
91+
],
92+
),
93+
);
94+
}
95+
}

0 commit comments

Comments
 (0)