Skip to content

Commit a739be3

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

File tree

12 files changed

+655
-449
lines changed

12 files changed

+655
-449
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/pages/login/login.page.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
2727
});
2828

2929
return Scaffold(
30-
body: LoginForm(),
30+
body: const LoginForm(),
3131
bottomNavigationBar: SafeArea(
3232
child: Padding(
3333
padding: const EdgeInsets.only(bottom: 16.0),

mobile/lib/providers/auth.provider.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,29 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
1212
import 'package:immich_mobile/services/api.service.dart';
1313
import 'package:immich_mobile/services/auth.service.dart';
1414
import 'package:immich_mobile/services/secure_storage.service.dart';
15+
import 'package:immich_mobile/services/server_info.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(
2440
ref.watch(authServiceProvider),
@@ -27,6 +43,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
2743
ref.watch(uploadServiceProvider),
2844
ref.watch(secureStorageServiceProvider),
2945
ref.watch(widgetServiceProvider),
46+
ref.watch(serverInfoServiceProvider),
3047
);
3148
});
3249

@@ -37,6 +54,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
3754
final UploadService _uploadService;
3855
final SecureStorageService _secureStorageService;
3956
final WidgetService _widgetService;
57+
final ServerInfoService _serverInfoService;
4058
final _log = Logger("AuthenticationNotifier");
4159

4260
static const Duration _timeoutDuration = Duration(seconds: 7);
@@ -48,6 +66,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
4866
this._uploadService,
4967
this._secureStorageService,
5068
this._widgetService,
69+
this._serverInfoService,
5170
) : super(
5271
const AuthState(
5372
deviceId: "",
@@ -64,6 +83,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
6483
return _authService.validateServerUrl(url);
6584
}
6685

86+
Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
87+
final sanitizedUrl = sanitizeUrl(serverUrl);
88+
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
89+
90+
final endpoint = await _authService.validateServerUrl(encodedUrl);
91+
92+
final features = await _serverInfoService.getServerFeatures();
93+
final config = await _serverInfoService.getServerConfig();
94+
95+
if (features == null || config == null) {
96+
return null;
97+
}
98+
99+
return ServerAuthSettings(
100+
endpoint: endpoint,
101+
isOAuthEnabled: features.oauthEnabled,
102+
isPasswordLoginEnabled: features.passwordLogin,
103+
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
104+
);
105+
}
106+
67107
/// Validating the url is the alternative connecting server url without
68108
/// saving the information to the local database
69109
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)