mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
Added mechanism of required password change of new user's first login (#272)
* Deprecate login scenarios that support pre-web era * refactor and simplify setup * Added user info to change password form * change isFistLogin column to shouldChangePassword * Implemented change user password * Implement the change password page for mobile * Change label * Added changes log and up minor version * Fixed typo in the release note * Up server version
This commit is contained in:
@@ -11,7 +11,7 @@ class AuthenticationState {
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final bool isAdmin;
|
||||
final bool isFirstLogin;
|
||||
final bool shouldChangePassword;
|
||||
final String profileImagePath;
|
||||
final DeviceInfoRemote deviceInfo;
|
||||
|
||||
@@ -24,7 +24,7 @@ class AuthenticationState {
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.isAdmin,
|
||||
required this.isFirstLogin,
|
||||
required this.shouldChangePassword,
|
||||
required this.profileImagePath,
|
||||
required this.deviceInfo,
|
||||
});
|
||||
@@ -38,7 +38,7 @@ class AuthenticationState {
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
bool? isAdmin,
|
||||
bool? isFirstLoggedIn,
|
||||
bool? shouldChangePassword,
|
||||
String? profileImagePath,
|
||||
DeviceInfoRemote? deviceInfo,
|
||||
}) {
|
||||
@@ -51,17 +51,12 @@ class AuthenticationState {
|
||||
firstName: firstName ?? this.firstName,
|
||||
lastName: lastName ?? this.lastName,
|
||||
isAdmin: isAdmin ?? this.isAdmin,
|
||||
isFirstLogin: isFirstLoggedIn ?? isFirstLogin,
|
||||
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
|
||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||
deviceInfo: deviceInfo ?? this.deviceInfo,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, isFirstLoggedIn: $isFirstLogin, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
final result = <String, dynamic>{};
|
||||
|
||||
@@ -73,7 +68,7 @@ class AuthenticationState {
|
||||
result.addAll({'firstName': firstName});
|
||||
result.addAll({'lastName': lastName});
|
||||
result.addAll({'isAdmin': isAdmin});
|
||||
result.addAll({'isFirstLogin': isFirstLogin});
|
||||
result.addAll({'shouldChangePassword': shouldChangePassword});
|
||||
result.addAll({'profileImagePath': profileImagePath});
|
||||
result.addAll({'deviceInfo': deviceInfo.toMap()});
|
||||
|
||||
@@ -90,7 +85,7 @@ class AuthenticationState {
|
||||
firstName: map['firstName'] ?? '',
|
||||
lastName: map['lastName'] ?? '',
|
||||
isAdmin: map['isAdmin'] ?? false,
|
||||
isFirstLogin: map['isFirstLogin'] ?? false,
|
||||
shouldChangePassword: map['shouldChangePassword'] ?? false,
|
||||
profileImagePath: map['profileImagePath'] ?? '',
|
||||
deviceInfo: DeviceInfoRemote.fromMap(map['deviceInfo']),
|
||||
);
|
||||
@@ -101,6 +96,11 @@ class AuthenticationState {
|
||||
factory AuthenticationState.fromJson(String source) =>
|
||||
AuthenticationState.fromMap(json.decode(source));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AuthenticationState(deviceId: $deviceId, deviceType: $deviceType, userId: $userId, userEmail: $userEmail, isAuthenticated: $isAuthenticated, firstName: $firstName, lastName: $lastName, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword, profileImagePath: $profileImagePath, deviceInfo: $deviceInfo)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
@@ -114,7 +114,7 @@ class AuthenticationState {
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.isFirstLogin == isFirstLogin &&
|
||||
other.shouldChangePassword == shouldChangePassword &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.deviceInfo == deviceInfo;
|
||||
}
|
||||
@@ -129,7 +129,7 @@ class AuthenticationState {
|
||||
firstName.hashCode ^
|
||||
lastName.hashCode ^
|
||||
isAdmin.hashCode ^
|
||||
isFirstLogin.hashCode ^
|
||||
shouldChangePassword.hashCode ^
|
||||
profileImagePath.hashCode ^
|
||||
deviceInfo.hashCode;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ class LogInReponse {
|
||||
final String lastName;
|
||||
final String profileImagePath;
|
||||
final bool isAdmin;
|
||||
final bool isFirstLogin;
|
||||
final bool shouldChangePassword;
|
||||
|
||||
LogInReponse({
|
||||
required this.accessToken,
|
||||
@@ -18,7 +18,7 @@ class LogInReponse {
|
||||
required this.lastName,
|
||||
required this.profileImagePath,
|
||||
required this.isAdmin,
|
||||
required this.isFirstLogin,
|
||||
required this.shouldChangePassword,
|
||||
});
|
||||
|
||||
LogInReponse copyWith({
|
||||
@@ -29,7 +29,7 @@ class LogInReponse {
|
||||
String? lastName,
|
||||
String? profileImagePath,
|
||||
bool? isAdmin,
|
||||
bool? isFirstLogin,
|
||||
bool? shouldChangePassword,
|
||||
}) {
|
||||
return LogInReponse(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
@@ -39,7 +39,7 @@ class LogInReponse {
|
||||
lastName: lastName ?? this.lastName,
|
||||
profileImagePath: profileImagePath ?? this.profileImagePath,
|
||||
isAdmin: isAdmin ?? this.isAdmin,
|
||||
isFirstLogin: isFirstLogin ?? this.isFirstLogin,
|
||||
shouldChangePassword: shouldChangePassword ?? this.shouldChangePassword,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class LogInReponse {
|
||||
result.addAll({'lastName': lastName});
|
||||
result.addAll({'profileImagePath': profileImagePath});
|
||||
result.addAll({'isAdmin': isAdmin});
|
||||
result.addAll({'isFirstLogin': isFirstLogin});
|
||||
result.addAll({'shouldChangePassword': shouldChangePassword});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class LogInReponse {
|
||||
lastName: map['lastName'] ?? '',
|
||||
profileImagePath: map['profileImagePath'] ?? '',
|
||||
isAdmin: map['isAdmin'] ?? false,
|
||||
isFirstLogin: map['isFirstLogin'] ?? false,
|
||||
shouldChangePassword: map['shouldChangePassword'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class LogInReponse {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, isFirstLogin: $isFirstLogin)';
|
||||
return 'LogInReponse(accessToken: $accessToken, userId: $userId, userEmail: $userEmail, firstName: $firstName, lastName: $lastName, profileImagePath: $profileImagePath, isAdmin: $isAdmin, shouldChangePassword: $shouldChangePassword)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -93,7 +93,7 @@ class LogInReponse {
|
||||
other.lastName == lastName &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.isFirstLogin == isFirstLogin;
|
||||
other.shouldChangePassword == shouldChangePassword;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -105,6 +105,6 @@ class LogInReponse {
|
||||
lastName.hashCode ^
|
||||
profileImagePath.hashCode ^
|
||||
isAdmin.hashCode ^
|
||||
isFirstLogin.hashCode;
|
||||
shouldChangePassword.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
lastName: '',
|
||||
profileImagePath: '',
|
||||
isAdmin: false,
|
||||
isFirstLogin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
deviceInfo: DeviceInfoRemote(
|
||||
id: 0,
|
||||
@@ -87,7 +87,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
lastName: payload.lastName,
|
||||
profileImagePath: payload.profileImagePath,
|
||||
isAdmin: payload.isAdmin,
|
||||
isFirstLoggedIn: payload.isFirstLogin,
|
||||
shouldChangePassword: payload.shouldChangePassword,
|
||||
);
|
||||
|
||||
if (isSavedLoginInfo) {
|
||||
@@ -111,8 +111,12 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
// Register device info
|
||||
try {
|
||||
Response res = await _networkService.postRequest(
|
||||
url: 'device-info',
|
||||
data: {'deviceId': state.deviceId, 'deviceType': state.deviceType});
|
||||
url: 'device-info',
|
||||
data: {
|
||||
'deviceId': state.deviceId,
|
||||
'deviceType': state.deviceType,
|
||||
},
|
||||
);
|
||||
|
||||
DeviceInfoRemote deviceInfo = DeviceInfoRemote.fromJson(res.toString());
|
||||
state = state.copyWith(deviceInfo: deviceInfo);
|
||||
@@ -133,7 +137,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
profileImagePath: '',
|
||||
isFirstLogin: false,
|
||||
shouldChangePassword: false,
|
||||
isAuthenticated: false,
|
||||
isAdmin: false,
|
||||
deviceInfo: DeviceInfoRemote(
|
||||
@@ -163,6 +167,24 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
|
||||
updateUserProfileImagePath(String path) {
|
||||
state = state.copyWith(profileImagePath: path);
|
||||
}
|
||||
|
||||
Future<bool> changePassword(String newPassword) async {
|
||||
Response res = await _networkService.putRequest(
|
||||
url: 'user',
|
||||
data: {
|
||||
'id': state.userId,
|
||||
'password': newPassword,
|
||||
'shouldChangePassword': false,
|
||||
},
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
state = state.copyWith(shouldChangePassword: false);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final authenticationProvider =
|
||||
|
||||
160
mobile/lib/modules/login/ui/change_password_form.dart
Normal file
160
mobile/lib/modules/login/ui/change_password_form.dart
Normal file
@@ -0,0 +1,160 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
|
||||
class ChangePasswordForm extends HookConsumerWidget {
|
||||
const ChangePasswordForm({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final passwordController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final confirmPasswordController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final authState = ref.watch(authenticationProvider);
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Change Password',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'Hi ${authState.firstName} ${authState.lastName},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
PasswordInput(controller: passwordController),
|
||||
ConfirmPasswordInput(
|
||||
originalController: passwordController,
|
||||
confirmController: confirmPasswordController,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: ChangePasswordButton(
|
||||
passwordController: passwordController),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
|
||||
const PasswordInput({Key? key, required this.controller}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: true,
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'New Password',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'New Password',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmPasswordInput extends StatelessWidget {
|
||||
final TextEditingController originalController;
|
||||
final TextEditingController confirmController;
|
||||
|
||||
const ConfirmPasswordInput({
|
||||
Key? key,
|
||||
required this.originalController,
|
||||
required this.confirmController,
|
||||
}) : super(key: key);
|
||||
|
||||
String? _validateInput(String? email) {
|
||||
if (confirmController.value != originalController.value) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: true,
|
||||
controller: confirmController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
hintText: 'Re-enter New Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChangePasswordButton extends ConsumerWidget {
|
||||
final TextEditingController passwordController;
|
||||
|
||||
const ChangePasswordButton({
|
||||
Key? key,
|
||||
required this.passwordController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
visualDensity: VisualDensity.standard,
|
||||
primary: Theme.of(context).primaryColor,
|
||||
onPrimary: Colors.grey[50],
|
||||
elevation: 2,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
||||
),
|
||||
onPressed: () async {
|
||||
var isSuccess = await ref
|
||||
.watch(authenticationProvider.notifier)
|
||||
.changePassword(passwordController.value.text);
|
||||
|
||||
if (isSuccess) {
|
||||
bool res =
|
||||
await ref.watch(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
ref.watch(websocketProvider.notifier).disconnect();
|
||||
AutoRouter.of(context).replace(const LoginRoute());
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
"Change Password",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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/routing/router.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';
|
||||
@@ -20,7 +21,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
final passwordController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final serverEndpointController =
|
||||
useTextEditingController(text: 'http://your-server-ip:2283');
|
||||
useTextEditingController(text: 'http://your-server-ip:2283/api');
|
||||
final isSaveLoginInfo = useState<bool>(false);
|
||||
|
||||
useEffect(() {
|
||||
@@ -106,9 +107,18 @@ class ServerEndpointInput extends StatelessWidget {
|
||||
: super(key: key);
|
||||
|
||||
String? _validateInput(String? url) {
|
||||
if (url == null) return null;
|
||||
if (!url.startsWith(RegExp(r'https?://')))
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.isEmpty) {
|
||||
return 'Server endpoint is required';
|
||||
}
|
||||
|
||||
if (!url.startsWith(RegExp(r'https?://'))) {
|
||||
return 'Please specify http:// or https://';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -117,9 +127,10 @@ class ServerEndpointInput extends StatelessWidget {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server Endpoint URL',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'http://your-server-ip:port'),
|
||||
labelText: 'Server Endpoint URL',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'http://your-server-ip:port',
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
);
|
||||
@@ -144,9 +155,10 @@ class EmailInput extends StatelessWidget {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'youremail@email.com'),
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'youremail@email.com',
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
);
|
||||
@@ -200,14 +212,19 @@ class LoginButton extends ConsumerWidget {
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
|
||||
var isAuthenticated = await ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.watch(authenticationProvider.notifier)
|
||||
.login(emailController.text, passwordController.text,
|
||||
serverEndpointController.text, isSavedLoginInfo);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Resume backup (if enable) then navigate
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||
|
||||
if (ref.watch(authenticationProvider).shouldChangePassword) {
|
||||
AutoRouter.of(context).push(const ChangePasswordRoute());
|
||||
} else {
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
AutoRouter.of(context).pushNamed("/tab-controller-page");
|
||||
}
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
|
||||
14
mobile/lib/modules/login/views/change_password_page.dart
Normal file
14
mobile/lib/modules/login/views/change_password_page.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/login/ui/change_password_form.dart';
|
||||
|
||||
class ChangePasswordPage extends HookConsumerWidget {
|
||||
const ChangePasswordPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const Scaffold(
|
||||
body: ChangePasswordForm(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user