mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 18:08:48 -07: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:
parent
2e85e18020
commit
5f00d8b9c6
@ -0,0 +1,3 @@
|
||||
* Fixed app does not resume back up when reopening a closed app
|
||||
* Fixed wrong asset count on the upload page
|
||||
* Added mechanism to change the password of new user on the first login (except Admin)
|
@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Beta"
|
||||
lane :beta do
|
||||
increment_version_number(
|
||||
version_number: "1.13.0"
|
||||
version_number: "1.14.0"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
@ -191,7 +191,7 @@ class ProfileDrawer extends HookConsumerWidget {
|
||||
),
|
||||
onTap: () async {
|
||||
bool res =
|
||||
await ref.read(authenticationProvider.notifier).logout();
|
||||
await ref.watch(authenticationProvider.notifier).logout();
|
||||
|
||||
if (res) {
|
||||
ref.watch(backupProvider.notifier).cancelBackup();
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
|
||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
|
||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
|
||||
@ -30,6 +31,7 @@ part 'router.gr.dart';
|
||||
routes: <AutoRoute>[
|
||||
AutoRoute(page: SplashScreenPage, initial: true),
|
||||
AutoRoute(page: LoginPage),
|
||||
AutoRoute(page: ChangePasswordPage),
|
||||
CustomRoute(
|
||||
page: TabControllerPage,
|
||||
guards: [AuthGuard],
|
||||
|
@ -29,6 +29,10 @@ class _$AppRouter extends RootStackRouter {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const LoginPage());
|
||||
},
|
||||
ChangePasswordRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData, child: const ChangePasswordPage());
|
||||
},
|
||||
TabControllerRoute.name: (routeData) {
|
||||
return CustomPage<dynamic>(
|
||||
routeData: routeData,
|
||||
@ -131,6 +135,7 @@ class _$AppRouter extends RootStackRouter {
|
||||
List<RouteConfig> get routes => [
|
||||
RouteConfig(SplashScreenRoute.name, path: '/'),
|
||||
RouteConfig(LoginRoute.name, path: '/login-page'),
|
||||
RouteConfig(ChangePasswordRoute.name, path: '/change-password-page'),
|
||||
RouteConfig(TabControllerRoute.name,
|
||||
path: '/tab-controller-page',
|
||||
guards: [
|
||||
@ -192,6 +197,15 @@ class LoginRoute extends PageRouteInfo<void> {
|
||||
static const String name = 'LoginRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ChangePasswordPage]
|
||||
class ChangePasswordRoute extends PageRouteInfo<void> {
|
||||
const ChangePasswordRoute()
|
||||
: super(ChangePasswordRoute.name, path: '/change-password-page');
|
||||
|
||||
static const String name = 'ChangePasswordRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [TabControllerPage]
|
||||
class TabControllerRoute extends PageRouteInfo<void> {
|
||||
|
@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: "none"
|
||||
version: 1.13.0+20
|
||||
version: 1.14.0+21
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
|
@ -30,7 +30,7 @@ export class AuthService {
|
||||
'lastName',
|
||||
'isAdmin',
|
||||
'profileImagePath',
|
||||
'isFirstLoggedIn',
|
||||
'shouldChangePassword',
|
||||
],
|
||||
},
|
||||
);
|
||||
@ -66,7 +66,7 @@ export class AuthService {
|
||||
lastName: validatedUser.lastName,
|
||||
isAdmin: validatedUser.isAdmin,
|
||||
profileImagePath: validatedUser.profileImagePath,
|
||||
isFirstLogin: validatedUser.isFirstLoggedIn,
|
||||
shouldChangePassword: validatedUser.shouldChangePassword,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ export class CreateUserDto {
|
||||
isAdmin?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
isFirstLoggedIn?: boolean;
|
||||
shouldChangePassword?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
id?: string;
|
||||
|
@ -32,6 +32,12 @@ export class UserController {
|
||||
return await this.userService.getAllUsers(authUser, isAll);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
async getUserInfo(@GetAuthUser() authUser: AuthUserDto) {
|
||||
return await this.userService.getUserInfo(authUser);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(AdminRolesGuard)
|
||||
@Post()
|
||||
|
@ -37,6 +37,10 @@ export class UserService {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserInfo(authUser: AuthUserDto) {
|
||||
return this.userRepository.findOne({ id: authUser.id });
|
||||
}
|
||||
|
||||
async getUserCount(isAdmin: boolean) {
|
||||
let users;
|
||||
|
||||
@ -89,7 +93,8 @@ export class UserService {
|
||||
user.lastName = updateUserDto.lastName || user.lastName;
|
||||
user.firstName = updateUserDto.firstName || user.firstName;
|
||||
user.profileImagePath = updateUserDto.profileImagePath || user.profileImagePath;
|
||||
user.isFirstLoggedIn = updateUserDto.isFirstLoggedIn || user.isFirstLoggedIn;
|
||||
user.shouldChangePassword =
|
||||
updateUserDto.shouldChangePassword != undefined ? updateUserDto.shouldChangePassword : user.shouldChangePassword;
|
||||
|
||||
// If payload includes password - Create new password for user
|
||||
if (updateUserDto.password) {
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
export const serverVersion = {
|
||||
major: 1,
|
||||
minor: 13,
|
||||
minor: 14,
|
||||
patch: 0,
|
||||
build: 20,
|
||||
build: 21,
|
||||
};
|
||||
|
@ -98,7 +98,7 @@ describe('User', () => {
|
||||
id: expect.anything(),
|
||||
createdAt: expect.anything(),
|
||||
isAdmin: false,
|
||||
isFirstLoggedIn: true,
|
||||
shouldChangePassword: true,
|
||||
profileImagePath: '',
|
||||
},
|
||||
{
|
||||
@ -108,7 +108,7 @@ describe('User', () => {
|
||||
id: expect.anything(),
|
||||
createdAt: expect.anything(),
|
||||
isAdmin: false,
|
||||
isFirstLoggedIn: true,
|
||||
shouldChangePassword: true,
|
||||
profileImagePath: '',
|
||||
},
|
||||
]),
|
||||
|
@ -27,7 +27,7 @@ export class UserEntity {
|
||||
profileImagePath!: string;
|
||||
|
||||
@Column()
|
||||
isFirstLoggedIn!: boolean;
|
||||
shouldChangePassword!: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: string;
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RenameIsFirstLoggedInColumn1656338626260 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE users
|
||||
RENAME COLUMN "isFirstLoggedIn" to "shouldChangePassword";
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE users
|
||||
RENAME COLUMN "shouldChangePassword" to "isFirstLoggedIn";
|
||||
`);
|
||||
}
|
||||
}
|
@ -1,74 +1,64 @@
|
||||
type AdminRegistrationResult = Promise<{
|
||||
error?: string
|
||||
success?: string
|
||||
user?: {
|
||||
email: string
|
||||
}
|
||||
}>
|
||||
|
||||
|
||||
error?: string;
|
||||
success?: string;
|
||||
user?: {
|
||||
email: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
type LoginResult = Promise<{
|
||||
error?: string
|
||||
success?: string
|
||||
needUpdate?: boolean
|
||||
needSelectAdmin?: boolean
|
||||
user?: {
|
||||
accessToken: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
isAdmin: boolean
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
}>
|
||||
error?: string;
|
||||
success?: string;
|
||||
user?: {
|
||||
accessToken: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
id: string;
|
||||
email: string;
|
||||
shouldChangePassword: boolean;
|
||||
};
|
||||
}>;
|
||||
|
||||
type UpdateResult = Promise<{
|
||||
error?: string
|
||||
success?: string,
|
||||
user?: {
|
||||
accessToken: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
isAdmin: boolean
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
}>
|
||||
|
||||
error?: string;
|
||||
success?: string;
|
||||
user?: {
|
||||
accessToken: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
export async function sendRegistrationForm(form: HTMLFormElement): AdminRegistrationResult {
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
headers: { accept: 'application/json' },
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
||||
export async function sendLoginForm(form: HTMLFormElement): LoginResult {
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
headers: { accept: 'application/json' },
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function sendUpdateForm(form: HTMLFormElement): UpdateResult {
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
|
||||
const response = await fetch(form.action, {
|
||||
method: form.method,
|
||||
body: new FormData(form),
|
||||
headers: { accept: 'application/json' },
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
@ -5,20 +5,36 @@
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
async function registerAdmin(event: SubmitEvent) {
|
||||
error = '';
|
||||
let password: string = '';
|
||||
let confirmPassowrd: string = '';
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
let canRegister = false;
|
||||
|
||||
const response = await sendRegistrationForm(formElement);
|
||||
|
||||
if (response.error) {
|
||||
error = JSON.stringify(response.error);
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canRegister = false;
|
||||
} else {
|
||||
error = '';
|
||||
canRegister = true;
|
||||
}
|
||||
}
|
||||
async function registerAdmin(event: SubmitEvent) {
|
||||
if (canRegister) {
|
||||
error = '';
|
||||
|
||||
if (response.success) {
|
||||
success = response.success;
|
||||
goto('/auth/login');
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
|
||||
const response = await sendRegistrationForm(formElement);
|
||||
|
||||
if (response.error) {
|
||||
error = JSON.stringify(response.error);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
success = response.success;
|
||||
goto('/auth/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -41,21 +57,33 @@
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Admin Password</label>
|
||||
<input class="immich-form-input" id="password" name="password" type="password" required />
|
||||
<input class="immich-form-input" id="password" name="password" type="password" required bind:value={password} />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">First Name</label>
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Last Name</label>
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400">{error}</p>
|
||||
<p class="text-red-400 ml-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
|
97
web/src/lib/components/forms/change-password-form.svelte
Normal file
97
web/src/lib/components/forms/change-password-form.svelte
Normal file
@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { session } from '$app/stores';
|
||||
|
||||
import { sendRegistrationForm, sendUpdateForm } from '$lib/auth-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import type { ImmichUser } from '../../models/immich-user';
|
||||
|
||||
export let user: ImmichUser;
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password: string = '';
|
||||
let confirmPassowrd: string = '';
|
||||
|
||||
let changeChagePassword = false;
|
||||
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
changeChagePassword = false;
|
||||
} else {
|
||||
error = '';
|
||||
changeChagePassword = true;
|
||||
}
|
||||
}
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function changePassword(event: SubmitEvent) {
|
||||
if (changeChagePassword) {
|
||||
error = '';
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
|
||||
const response = await sendUpdateForm(formElement);
|
||||
|
||||
if (response.error) {
|
||||
error = JSON.stringify(response.error);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
success = 'Password has been changed';
|
||||
|
||||
dispatch('success');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Chage Password</h1>
|
||||
|
||||
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
|
||||
Hi {user.firstName}
|
||||
{user.lastName} ({user.email}),
|
||||
<br />
|
||||
<br />
|
||||
This 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={changePassword} method="post" autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">New Password</label>
|
||||
<input class="immich-form-input" id="password" name="password" type="password" required bind:value={password} />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full"
|
||||
>Change Password</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
@ -5,23 +5,39 @@
|
||||
let error: string;
|
||||
let success: string;
|
||||
|
||||
let password: string = '';
|
||||
let confirmPassowrd: string = '';
|
||||
|
||||
let canCreateUser = false;
|
||||
|
||||
$: {
|
||||
if (password !== confirmPassowrd && confirmPassowrd.length > 0) {
|
||||
error = 'Password does not match';
|
||||
canCreateUser = false;
|
||||
} else {
|
||||
error = '';
|
||||
canCreateUser = true;
|
||||
}
|
||||
}
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function registerUser(event: SubmitEvent) {
|
||||
error = '';
|
||||
if (canCreateUser) {
|
||||
error = '';
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
|
||||
const response = await sendRegistrationForm(formElement);
|
||||
const response = await sendRegistrationForm(formElement);
|
||||
|
||||
if (response.error) {
|
||||
error = JSON.stringify(response.error);
|
||||
}
|
||||
if (response.error) {
|
||||
error = JSON.stringify(response.error);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
success = 'New user created';
|
||||
if (response.success) {
|
||||
success = 'New user created';
|
||||
|
||||
dispatch('user-created');
|
||||
dispatch('user-created');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@ -43,25 +59,37 @@
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Password</label>
|
||||
<input class="immich-form-input" id="password" name="password" type="password" required />
|
||||
<input class="immich-form-input" id="password" name="password" type="password" required bind:value={password} />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">First Name</label>
|
||||
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="confirmPassword"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={confirmPassowrd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First Name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="password">Last Name</label>
|
||||
<label class="immich-form-label" for="lastName">Last Name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400">{error}</p>
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<p class="text-immich-primary">{success}</p>
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
|
@ -18,14 +18,6 @@
|
||||
error = response.error;
|
||||
}
|
||||
|
||||
if (response.needUpdate) {
|
||||
return dispatch('need-update');
|
||||
}
|
||||
|
||||
if (response.needSelectAdmin) {
|
||||
return dispatch('need-select-admin');
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
$session.user = {
|
||||
accessToken: response.user!.accessToken,
|
||||
@ -36,6 +28,10 @@
|
||||
email: response.user!.email,
|
||||
};
|
||||
|
||||
if (!response.user?.isAdmin && response.user?.shouldChangePassword) {
|
||||
return dispatch('first-login');
|
||||
}
|
||||
|
||||
return dispatch('success');
|
||||
}
|
||||
}
|
||||
|
@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { session } from '$app/stores';
|
||||
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { ImmichUser } from '../../models/immich-user';
|
||||
import Check from 'svelte-material-icons/Check.svelte';
|
||||
|
||||
let error: string = '';
|
||||
let allUsers: Array<ImmichUser> = [];
|
||||
let selectedUserId: string;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(async () => {
|
||||
const res = await fetch('/auth/login/api/get-users', { method: 'GET' });
|
||||
const data = await res.json();
|
||||
allUsers = data.allUsers;
|
||||
});
|
||||
|
||||
const assignAdmin = async () => {
|
||||
const res = await fetch('/auth/login/api/select-admin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: selectedUserId,
|
||||
isAdmin: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
const data = await res.json();
|
||||
|
||||
$session.user = {
|
||||
accessToken: '',
|
||||
firstName: data.userInfo.firstName,
|
||||
lastName: data.userInfo.lastName,
|
||||
isAdmin: data.userInfo.isAdmin,
|
||||
id: data.userInfo.id,
|
||||
email: data.userInfo.email,
|
||||
};
|
||||
|
||||
dispatch('success');
|
||||
} else {
|
||||
error = JSON.stringify(await res.json());
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Select Admin</h1>
|
||||
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
|
||||
There are multiple users on the server, and none have been selected to be the admin. Please assign one as the
|
||||
admin, who will be responsible for administrative tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-xs m-4">USERS ON SERVER, CLICK TO SELECT ONE</div>
|
||||
<div class="overflow-y-auto rounded-md max-h-[300px] block border mx-4 px-4 py-2">
|
||||
{#each allUsers as user, i}
|
||||
<div
|
||||
class="p-4 flex justify-between place-items-center my-4 rounded-md hover:cursor-pointer shadow-sm bg-gray-50 hover:bg-gray-100"
|
||||
on:click={() => (selectedUserId = user.id)}
|
||||
>
|
||||
<p class="test-sm text-slate-600">{i + 1} | {user.email}</p>
|
||||
|
||||
<!-- Icon -->
|
||||
{#if selectedUserId == user.id}
|
||||
<div
|
||||
in:fade={{ duration: 100 }}
|
||||
class="border rounded-full border-gray-300 bg-immich-primary w-8 h-8 flex place-items-center place-content-center"
|
||||
>
|
||||
<Check color="white" size="24" />
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade={{ duration: 100 }} class="border rounded-full border-gray-300 w-8 h-8" />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="text-xs m-4 text-red-400">Error: {error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
|
||||
on:click={assignAdmin}>Assign as Admin</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { session } from '$app/stores';
|
||||
import { sendUpdateForm } from '$lib/auth-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
let error: string;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
async function updateInfo(event: SubmitEvent) {
|
||||
error = '';
|
||||
|
||||
const formElement = event.target as HTMLFormElement;
|
||||
|
||||
const response = await sendUpdateForm(formElement);
|
||||
|
||||
if (response.error) {
|
||||
error = response.error;
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
$session.user = {
|
||||
accessToken: response.user!.accessToken,
|
||||
firstName: response.user!.firstName,
|
||||
lastName: response.user!.lastName,
|
||||
isAdmin: response.user!.isAdmin,
|
||||
id: response.user!.id,
|
||||
email: response.user!.email,
|
||||
};
|
||||
|
||||
dispatch('success');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border bg-white p-4 shadow-sm w-[500px] rounded-md py-8">
|
||||
<div class="flex flex-col place-items-center place-content-center gap-4 px-4">
|
||||
<img class="text-center" src="/immich-logo.svg" height="100" width="100" alt="immich-logo" />
|
||||
<h1 class="text-2xl text-immich-primary font-medium">Update User Info</h1>
|
||||
<p class="text-sm border rounded-md p-4 font-mono text-gray-600">
|
||||
Your account doesn't have information about your name, please update to continue the login process.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={updateInfo} method="post" action="/auth/login/update" autocomplete="off">
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="firstName">First name</label>
|
||||
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="lastName">Last name</label>
|
||||
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 pl-4">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex w-full">
|
||||
<button
|
||||
type="submit"
|
||||
class="m-4 p-2 bg-immich-primary hover:bg-immich-primary/75 px-6 py-4 text-white rounded-md shadow-md w-full font-semibold"
|
||||
>Update</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
@ -1,7 +1,9 @@
|
||||
export type ImmichUser = {
|
||||
id: string,
|
||||
email: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
isAdmin: boolean,
|
||||
}
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
profileImagePath: string;
|
||||
shouldChangePassword: boolean;
|
||||
};
|
||||
|
75
web/src/routes/auth/change-password/index.svelte
Normal file
75
web/src/routes/auth/change-password/index.svelte
Normal file
@ -0,0 +1,75 @@
|
||||
<script context="module" lang="ts">
|
||||
export const prerender = false;
|
||||
|
||||
import type { Load } from '@sveltejs/kit';
|
||||
import type { ImmichUser } from '$lib/models/immich-user';
|
||||
|
||||
export const load: Load = async ({ session }) => {
|
||||
if (!session.user) {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/auth/login',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(serverEndpoint + '/user/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + session.user.accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
const userInfo: ImmichUser = await res.json();
|
||||
|
||||
if (userInfo.shouldChangePassword) {
|
||||
return {
|
||||
status: 200,
|
||||
props: {
|
||||
user: userInfo,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos',
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('ERROR Getting user info', e);
|
||||
return {
|
||||
status: 302,
|
||||
redirect: '/photos',
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { session } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import ChangePasswordForm from '../../../lib/components/forms/change-password-form.svelte';
|
||||
import { serverEndpoint } from '../../../lib/constants';
|
||||
|
||||
export let user: ImmichUser;
|
||||
|
||||
const onSuccessHandler = async () => {
|
||||
const res = await fetch('/auth/logout', { method: 'POST' });
|
||||
|
||||
if (res.status == 200 && res.statusText == 'OK') {
|
||||
goto('/auth/login');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Immich - Change Password</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="h-screen w-screen flex place-items-center place-content-center">
|
||||
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
||||
<ChangePasswordForm {user} on:success={onSuccessHandler} />
|
||||
</div>
|
||||
</section>
|
39
web/src/routes/auth/change-password/index.ts
Normal file
39
web/src/routes/auth/change-password/index.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { serverEndpoint } from '$lib/constants';
|
||||
|
||||
export const post: RequestHandler = async ({ request, locals }) => {
|
||||
const form = await request.formData();
|
||||
|
||||
const password = form.get('password');
|
||||
|
||||
const payload = {
|
||||
id: locals.user?.id,
|
||||
password,
|
||||
shouldChangePassword: false,
|
||||
};
|
||||
|
||||
const res = await fetch(`${serverEndpoint}/user`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${locals.user?.accessToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
success: 'Succesfully change password',
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: await res.json(),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
@ -3,25 +3,10 @@
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import LoginForm from '$lib/components/forms/login-form.svelte';
|
||||
import UpdateForm from '../../../lib/components/forms/update-form.svelte';
|
||||
import SelectAdminForm from '../../../lib/components/forms/select-admin-form.svelte';
|
||||
|
||||
let shouldShowUpdateForm = false;
|
||||
let shouldShowSelectAdminForm = false;
|
||||
|
||||
const onLoginSuccess = async () => {
|
||||
goto('/photos');
|
||||
};
|
||||
|
||||
const onNeedUpdate = () => {
|
||||
shouldShowUpdateForm = true;
|
||||
shouldShowSelectAdminForm = false;
|
||||
};
|
||||
|
||||
const onNeedSelectAdmin = () => {
|
||||
shouldShowUpdateForm = false;
|
||||
shouldShowSelectAdminForm = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -29,21 +14,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<section class="h-screen w-screen flex place-items-center place-content-center">
|
||||
{#if !shouldShowUpdateForm && !shouldShowSelectAdminForm}
|
||||
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
||||
<LoginForm on:success={onLoginSuccess} on:need-update={onNeedUpdate} on:need-select-admin={onNeedSelectAdmin} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowUpdateForm}
|
||||
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
||||
<UpdateForm on:success={onLoginSuccess} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowSelectAdminForm}
|
||||
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
||||
<SelectAdminForm on:success={onLoginSuccess} />
|
||||
</div>
|
||||
{/if}
|
||||
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
|
||||
<LoginForm on:success={onLoginSuccess} on:first-login={() => goto('/auth/change-password')} />
|
||||
</div>
|
||||
</section>
|
||||
|
@ -1,229 +1,81 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { serverEndpoint } from '$lib/constants';
|
||||
import * as cookie from 'cookie'
|
||||
import * as cookie from 'cookie';
|
||||
import { getRequest, putRequest } from '$lib/api';
|
||||
|
||||
type LoggedInUser = {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
type AuthUser = {
|
||||
accessToken: string;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isAdmin: boolean;
|
||||
shouldChangePassword: boolean;
|
||||
};
|
||||
|
||||
export const post: RequestHandler = async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const form = await request.formData();
|
||||
|
||||
const email = form.get('email')
|
||||
const password = form.get('password')
|
||||
const email = form.get('email');
|
||||
const password = form.get('password');
|
||||
|
||||
const payload = {
|
||||
email,
|
||||
password,
|
||||
}
|
||||
const payload = {
|
||||
email,
|
||||
password,
|
||||
};
|
||||
|
||||
const res = await fetch(`${serverEndpoint}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const res = await fetch(`${serverEndpoint}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.status === 201) {
|
||||
// Login success
|
||||
const loggedInUser = await res.json() as LoggedInUser;
|
||||
if (res.status === 201) {
|
||||
// Login success
|
||||
const authUser = (await res.json()) as AuthUser;
|
||||
|
||||
/**
|
||||
* Support legacy users with two scenario
|
||||
*
|
||||
* Scenario 1 - If one user exists on the server - make the user admin and ask for name.
|
||||
* Scenario 2 - After assigned as admin, scenario 1 user not complete update form with names
|
||||
* Scenario 3 - If two users exists on the server and no admin - ask to choose which one will be made admin
|
||||
*/
|
||||
|
||||
|
||||
// check how many user on the server
|
||||
const { userCount } = await getRequest('user/count', '');
|
||||
const { userCount: adminUserCount } = await getRequest('user/count?isAdmin=true', '')
|
||||
/**
|
||||
* Scenario 1 handler
|
||||
*/
|
||||
if (userCount == 1 && !loggedInUser.isAdmin) {
|
||||
|
||||
const updatedUser = await putRequest('user', {
|
||||
id: loggedInUser.userId,
|
||||
isAdmin: true
|
||||
}, loggedInUser.accessToken)
|
||||
|
||||
|
||||
/**
|
||||
* Scenario 2 handler for current admin user
|
||||
*/
|
||||
let bodyResponse = { success: true, needUpdate: false }
|
||||
|
||||
if (loggedInUser.firstName == "" || loggedInUser.lastName == "") {
|
||||
bodyResponse = { success: false, needUpdate: true }
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
...bodyResponse,
|
||||
user: {
|
||||
id: updatedUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
isAdmin: updatedUser.isAdmin,
|
||||
email: updatedUser.email,
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'Set-Cookie': cookie.serialize('session', JSON.stringify(
|
||||
{
|
||||
id: updatedUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
isAdmin: updatedUser.isAdmin,
|
||||
email: updatedUser.email,
|
||||
}), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Scenario 3 handler
|
||||
*/
|
||||
if (userCount >= 2 && adminUserCount == 0) {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
needSelectAdmin: true,
|
||||
user: {
|
||||
id: loggedInUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: loggedInUser.firstName,
|
||||
lastName: loggedInUser.lastName,
|
||||
isAdmin: loggedInUser.isAdmin,
|
||||
email: loggedInUser.userEmail
|
||||
},
|
||||
success: 'success'
|
||||
},
|
||||
headers: {
|
||||
'Set-Cookie': cookie.serialize('session', JSON.stringify(
|
||||
{
|
||||
id: loggedInUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: loggedInUser.firstName,
|
||||
lastName: loggedInUser.lastName,
|
||||
isAdmin: loggedInUser.isAdmin,
|
||||
email: loggedInUser.userEmail
|
||||
}), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenario 2 handler
|
||||
*/
|
||||
if (loggedInUser.firstName == "" || loggedInUser.lastName == "") {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
needUpdate: true,
|
||||
user: {
|
||||
id: loggedInUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: loggedInUser.firstName,
|
||||
lastName: loggedInUser.lastName,
|
||||
isAdmin: loggedInUser.isAdmin,
|
||||
email: loggedInUser.userEmail
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
'Set-Cookie': cookie.serialize('session', JSON.stringify(
|
||||
{
|
||||
id: loggedInUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: loggedInUser.firstName,
|
||||
lastName: loggedInUser.lastName,
|
||||
isAdmin: loggedInUser.isAdmin,
|
||||
email: loggedInUser.userEmail
|
||||
}), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: loggedInUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: loggedInUser.firstName,
|
||||
lastName: loggedInUser.lastName,
|
||||
isAdmin: loggedInUser.isAdmin,
|
||||
email: loggedInUser.userEmail
|
||||
},
|
||||
success: 'success'
|
||||
},
|
||||
headers: {
|
||||
'Set-Cookie': cookie.serialize('session', JSON.stringify(
|
||||
{
|
||||
id: loggedInUser.userId,
|
||||
accessToken: loggedInUser.accessToken,
|
||||
firstName: loggedInUser.firstName,
|
||||
lastName: loggedInUser.lastName,
|
||||
isAdmin: loggedInUser.isAdmin,
|
||||
email: loggedInUser.userEmail,
|
||||
}), {
|
||||
// send cookie for every page
|
||||
path: '/',
|
||||
|
||||
// server side only cookie so you can't use `document.cookie`
|
||||
httpOnly: true,
|
||||
|
||||
// only requests from same site can send cookies
|
||||
// and serves to protect from CSRF
|
||||
// https://developer.mozilla.org/en-US/docs/Glossary/CSRF
|
||||
sameSite: 'strict',
|
||||
|
||||
// set cookie to expire after a month
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: 'Incorrect email or password'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
user: {
|
||||
id: authUser.userId,
|
||||
accessToken: authUser.accessToken,
|
||||
firstName: authUser.firstName,
|
||||
lastName: authUser.lastName,
|
||||
isAdmin: authUser.isAdmin,
|
||||
email: authUser.userEmail,
|
||||
shouldChangePassword: authUser.shouldChangePassword,
|
||||
},
|
||||
success: 'success',
|
||||
},
|
||||
headers: {
|
||||
'Set-Cookie': cookie.serialize(
|
||||
'session',
|
||||
JSON.stringify({
|
||||
id: authUser.userId,
|
||||
accessToken: authUser.accessToken,
|
||||
firstName: authUser.firstName,
|
||||
lastName: authUser.lastName,
|
||||
isAdmin: authUser.isAdmin,
|
||||
email: authUser.userEmail,
|
||||
}),
|
||||
{
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error: 'Incorrect email or password',
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -35,7 +35,6 @@
|
||||
<script lang="ts">
|
||||
import { serverEndpoint } from '$lib/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let isAdminUserExist: boolean;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user