From 5f00d8b9c6169d6a85a02f2ce40040f313fdcc48 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 27 Jun 2022 15:13:07 -0500 Subject: [PATCH] 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 --- .../metadata/android/en-US/changelogs/21.txt | 3 + mobile/ios/fastlane/Fastfile | 2 +- .../lib/modules/home/ui/profile_drawer.dart | 2 +- .../models/authentication_state.model.dart | 26 +- .../login/models/login_response.model.dart | 18 +- .../providers/authentication.provider.dart | 32 +- .../login/ui/change_password_form.dart | 160 ++++++++++ mobile/lib/modules/login/ui/login_form.dart | 41 ++- .../login/views/change_password_page.dart | 14 + mobile/lib/routing/router.dart | 2 + mobile/lib/routing/router.gr.dart | 14 + mobile/pubspec.yaml | 2 +- .../immich/src/api-v1/auth/auth.service.ts | 4 +- .../src/api-v1/user/dto/create-user.dto.ts | 2 +- .../immich/src/api-v1/user/user.controller.ts | 6 + .../immich/src/api-v1/user/user.service.ts | 7 +- .../src/constants/server_version.constant.ts | 4 +- server/apps/immich/test/user.e2e-spec.ts | 4 +- .../libs/database/src/entities/user.entity.ts | 2 +- ...56338626260-RenameIsFirstLoggedInColumn.ts | 17 ++ web/src/lib/auth-api.ts | 104 +++---- .../forms/admin-registration-form.svelte | 56 +++- .../forms/change-password-form.svelte | 97 ++++++ .../components/forms/create-user-form.svelte | 56 +++- .../lib/components/forms/login-form.svelte | 12 +- .../components/forms/select-admin-form.svelte | 93 ------ .../lib/components/forms/update-form.svelte | 68 ----- web/src/lib/models/immich-user.ts | 14 +- .../routes/auth/change-password/index.svelte | 75 +++++ web/src/routes/auth/change-password/index.ts | 39 +++ web/src/routes/auth/login/index.svelte | 35 +-- web/src/routes/auth/login/index.ts | 288 +++++------------- web/src/routes/index.svelte | 1 - 33 files changed, 738 insertions(+), 562 deletions(-) create mode 100644 mobile/android/fastlane/metadata/android/en-US/changelogs/21.txt create mode 100644 mobile/lib/modules/login/ui/change_password_form.dart create mode 100644 mobile/lib/modules/login/views/change_password_page.dart create mode 100644 server/libs/database/src/migrations/1656338626260-RenameIsFirstLoggedInColumn.ts create mode 100644 web/src/lib/components/forms/change-password-form.svelte delete mode 100644 web/src/lib/components/forms/select-admin-form.svelte delete mode 100644 web/src/lib/components/forms/update-form.svelte create mode 100644 web/src/routes/auth/change-password/index.svelte create mode 100644 web/src/routes/auth/change-password/index.ts diff --git a/mobile/android/fastlane/metadata/android/en-US/changelogs/21.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/21.txt new file mode 100644 index 0000000000..c09aa043d4 --- /dev/null +++ b/mobile/android/fastlane/metadata/android/en-US/changelogs/21.txt @@ -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) \ No newline at end of file diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 8fc1ed9f0c..dc18950208 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -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, diff --git a/mobile/lib/modules/home/ui/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer.dart index 4bfdc8c60f..9c75e52d8a 100644 --- a/mobile/lib/modules/home/ui/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer.dart @@ -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(); diff --git a/mobile/lib/modules/login/models/authentication_state.model.dart b/mobile/lib/modules/login/models/authentication_state.model.dart index aae86f6aba..6603021bb8 100644 --- a/mobile/lib/modules/login/models/authentication_state.model.dart +++ b/mobile/lib/modules/login/models/authentication_state.model.dart @@ -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 toMap() { final result = {}; @@ -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; } diff --git a/mobile/lib/modules/login/models/login_response.model.dart b/mobile/lib/modules/login/models/login_response.model.dart index 2ba840e4dd..6e825eff67 100644 --- a/mobile/lib/modules/login/models/login_response.model.dart +++ b/mobile/lib/modules/login/models/login_response.model.dart @@ -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; } } diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index 014912bd72..0634e5731a 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -24,7 +24,7 @@ class AuthenticationNotifier extends StateNotifier { lastName: '', profileImagePath: '', isAdmin: false, - isFirstLogin: false, + shouldChangePassword: false, isAuthenticated: false, deviceInfo: DeviceInfoRemote( id: 0, @@ -87,7 +87,7 @@ class AuthenticationNotifier extends StateNotifier { 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 { // 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 { firstName: '', lastName: '', profileImagePath: '', - isFirstLogin: false, + shouldChangePassword: false, isAuthenticated: false, isAdmin: false, deviceInfo: DeviceInfoRemote( @@ -163,6 +167,24 @@ class AuthenticationNotifier extends StateNotifier { updateUserProfileImagePath(String path) { state = state.copyWith(profileImagePath: path); } + + Future 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 = diff --git a/mobile/lib/modules/login/ui/change_password_form.dart b/mobile/lib/modules/login/ui/change_password_form.dart new file mode 100644 index 0000000000..b71b821e63 --- /dev/null +++ b/mobile/lib/modules/login/ui/change_password_form.dart @@ -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), + )); + } +} diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index 34260d9e3c..70728e2097 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -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(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, diff --git a/mobile/lib/modules/login/views/change_password_page.dart b/mobile/lib/modules/login/views/change_password_page.dart new file mode 100644 index 0000000000..b5c8a6ca0d --- /dev/null +++ b/mobile/lib/modules/login/views/change_password_page.dart @@ -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(), + ); + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index b520f49a1d..8100248f03 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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(page: SplashScreenPage, initial: true), AutoRoute(page: LoginPage), + AutoRoute(page: ChangePasswordPage), CustomRoute( page: TabControllerPage, guards: [AuthGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 4cf5338186..598e81b77a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -29,6 +29,10 @@ class _$AppRouter extends RootStackRouter { return MaterialPageX( routeData: routeData, child: const LoginPage()); }, + ChangePasswordRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, child: const ChangePasswordPage()); + }, TabControllerRoute.name: (routeData) { return CustomPage( routeData: routeData, @@ -131,6 +135,7 @@ class _$AppRouter extends RootStackRouter { List 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 { static const String name = 'LoginRoute'; } +/// generated route for +/// [ChangePasswordPage] +class ChangePasswordRoute extends PageRouteInfo { + const ChangePasswordRoute() + : super(ChangePasswordRoute.name, path: '/change-password-page'); + + static const String name = 'ChangePasswordRoute'; +} + /// generated route for /// [TabControllerPage] class TabControllerRoute extends PageRouteInfo { diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 5e7d855a48..63ee71c096 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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" diff --git a/server/apps/immich/src/api-v1/auth/auth.service.ts b/server/apps/immich/src/api-v1/auth/auth.service.ts index 675acd2af2..bc9d4f0080 100644 --- a/server/apps/immich/src/api-v1/auth/auth.service.ts +++ b/server/apps/immich/src/api-v1/auth/auth.service.ts @@ -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, }; } diff --git a/server/apps/immich/src/api-v1/user/dto/create-user.dto.ts b/server/apps/immich/src/api-v1/user/dto/create-user.dto.ts index e3ef6eb5ff..b8df1d249c 100644 --- a/server/apps/immich/src/api-v1/user/dto/create-user.dto.ts +++ b/server/apps/immich/src/api-v1/user/dto/create-user.dto.ts @@ -20,7 +20,7 @@ export class CreateUserDto { isAdmin?: boolean; @IsOptional() - isFirstLoggedIn?: boolean; + shouldChangePassword?: boolean; @IsOptional() id?: string; diff --git a/server/apps/immich/src/api-v1/user/user.controller.ts b/server/apps/immich/src/api-v1/user/user.controller.ts index a81de1301d..3a1f422790 100644 --- a/server/apps/immich/src/api-v1/user/user.controller.ts +++ b/server/apps/immich/src/api-v1/user/user.controller.ts @@ -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() diff --git a/server/apps/immich/src/api-v1/user/user.service.ts b/server/apps/immich/src/api-v1/user/user.service.ts index d0ad2d0843..1ab2df7f31 100644 --- a/server/apps/immich/src/api-v1/user/user.service.ts +++ b/server/apps/immich/src/api-v1/user/user.service.ts @@ -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) { diff --git a/server/apps/immich/src/constants/server_version.constant.ts b/server/apps/immich/src/constants/server_version.constant.ts index 7de061a8f9..e862cbbcad 100644 --- a/server/apps/immich/src/constants/server_version.constant.ts +++ b/server/apps/immich/src/constants/server_version.constant.ts @@ -3,7 +3,7 @@ export const serverVersion = { major: 1, - minor: 13, + minor: 14, patch: 0, - build: 20, + build: 21, }; diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index ab87fedcfb..42f6f7f829 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -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: '', }, ]), diff --git a/server/libs/database/src/entities/user.entity.ts b/server/libs/database/src/entities/user.entity.ts index c9ae1e2bb0..40f5273471 100644 --- a/server/libs/database/src/entities/user.entity.ts +++ b/server/libs/database/src/entities/user.entity.ts @@ -27,7 +27,7 @@ export class UserEntity { profileImagePath!: string; @Column() - isFirstLoggedIn!: boolean; + shouldChangePassword!: boolean; @CreateDateColumn() createdAt!: string; diff --git a/server/libs/database/src/migrations/1656338626260-RenameIsFirstLoggedInColumn.ts b/server/libs/database/src/migrations/1656338626260-RenameIsFirstLoggedInColumn.ts new file mode 100644 index 0000000000..c4e4d7cd63 --- /dev/null +++ b/server/libs/database/src/migrations/1656338626260-RenameIsFirstLoggedInColumn.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameIsFirstLoggedInColumn1656338626260 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE users + RENAME COLUMN "isFirstLoggedIn" to "shouldChangePassword"; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE users + RENAME COLUMN "shouldChangePassword" to "isFirstLoggedIn"; + `); + } +} diff --git a/web/src/lib/auth-api.ts b/web/src/lib/auth-api.ts index 318755e836..5b483726f3 100644 --- a/web/src/lib/auth-api.ts +++ b/web/src/lib/auth-api.ts @@ -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(); } - diff --git a/web/src/lib/components/forms/admin-registration-form.svelte b/web/src/lib/components/forms/admin-registration-form.svelte index 81e3f3848d..a74234128e 100644 --- a/web/src/lib/components/forms/admin-registration-form.svelte +++ b/web/src/lib/components/forms/admin-registration-form.svelte @@ -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'); + } } } @@ -41,21 +57,33 @@
- +
- + + +
+ +
+
- +
{#if error} -

{error}

+

{error}

{/if} {#if success} diff --git a/web/src/lib/components/forms/change-password-form.svelte b/web/src/lib/components/forms/change-password-form.svelte new file mode 100644 index 0000000000..b956db6f2a --- /dev/null +++ b/web/src/lib/components/forms/change-password-form.svelte @@ -0,0 +1,97 @@ + + +
+
+ immich-logo +

Chage Password

+ +

+ Hi {user.firstName} + {user.lastName} ({user.email}), +
+
+ 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. +

+
+ +
+
+ + +
+ +
+ + +
+ + {#if error} +

{error}

+ {/if} + + {#if success} +

{success}

+ {/if} +
+ +
+
+
diff --git a/web/src/lib/components/forms/create-user-form.svelte b/web/src/lib/components/forms/create-user-form.svelte index 0d58f18100..bdbe4642d7 100644 --- a/web/src/lib/components/forms/create-user-form.svelte +++ b/web/src/lib/components/forms/create-user-form.svelte @@ -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'); + } } } @@ -43,25 +59,37 @@
- +
- + + +
+ +
+
- +
{#if error} -

{error}

+

{error}

{/if} {#if success} -

{success}

+

{success}

{/if}
-
- diff --git a/web/src/lib/components/forms/update-form.svelte b/web/src/lib/components/forms/update-form.svelte deleted file mode 100644 index 07bb5d3e27..0000000000 --- a/web/src/lib/components/forms/update-form.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - -
-
- immich-logo -

Update User Info

-

- Your account doesn't have information about your name, please update to continue the login process. -

-
- -
-
- - -
- -
- - -
- - {#if error} -

{error}

- {/if} - -
- -
-
-
diff --git a/web/src/lib/models/immich-user.ts b/web/src/lib/models/immich-user.ts index 7eb9e59dc4..c0bf781640 100644 --- a/web/src/lib/models/immich-user.ts +++ b/web/src/lib/models/immich-user.ts @@ -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; +}; diff --git a/web/src/routes/auth/change-password/index.svelte b/web/src/routes/auth/change-password/index.svelte new file mode 100644 index 0000000000..4ee49ec2bb --- /dev/null +++ b/web/src/routes/auth/change-password/index.svelte @@ -0,0 +1,75 @@ + + + + + + Immich - Change Password + + +
+
+ +
+
diff --git a/web/src/routes/auth/change-password/index.ts b/web/src/routes/auth/change-password/index.ts new file mode 100644 index 0000000000..4abbb4bee4 --- /dev/null +++ b/web/src/routes/auth/change-password/index.ts @@ -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(), + }, + }; + } +}; diff --git a/web/src/routes/auth/login/index.svelte b/web/src/routes/auth/login/index.svelte index 4304af7446..0e00cd31aa 100644 --- a/web/src/routes/auth/login/index.svelte +++ b/web/src/routes/auth/login/index.svelte @@ -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; - }; @@ -29,21 +14,7 @@
- {#if !shouldShowUpdateForm && !shouldShowSelectAdminForm} -
- -
- {/if} - - {#if shouldShowUpdateForm} -
- -
- {/if} - - {#if shouldShowSelectAdminForm} -
- -
- {/if} +
+ goto('/auth/change-password')} /> +
diff --git a/web/src/routes/auth/login/index.ts b/web/src/routes/auth/login/index.ts index 965cb14b4c..64db005540 100644 --- a/web/src/routes/auth/login/index.ts +++ b/web/src/routes/auth/login/index.ts @@ -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' - } - } - } - - -} \ No newline at end of file + 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', + }, + }; + } +}; diff --git a/web/src/routes/index.svelte b/web/src/routes/index.svelte index d173d3c4d9..25fd6b01b3 100644 --- a/web/src/routes/index.svelte +++ b/web/src/routes/index.svelte @@ -35,7 +35,6 @@