From 42d8548960dc6a59b906ad0794746b22cdfde423 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 18 Aug 2019 13:11:30 +0100 Subject: [PATCH] Finished new user invite flow --- .../Controllers/Auth/UserInviteController.php | 106 ++++++++++++++++++ app/Http/Controllers/UserController.php | 20 +++- resources/assets/js/components/index.js | 2 + .../assets/js/components/new-user-password.js | 28 +++++ .../assets/js/components/toggle-switch.js | 5 + resources/lang/en/auth.php | 6 +- resources/lang/en/errors.php | 2 +- resources/lang/en/settings.php | 4 +- .../views/auth/invite-set-password.blade.php | 27 +++++ resources/views/users/form.blade.php | 41 +++++-- routes/web.php | 4 + 11 files changed, 225 insertions(+), 20 deletions(-) create mode 100644 app/Http/Controllers/Auth/UserInviteController.php create mode 100644 resources/assets/js/components/new-user-password.js create mode 100644 resources/views/auth/invite-set-password.blade.php diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php new file mode 100644 index 000000000..5d9373f45 --- /dev/null +++ b/app/Http/Controllers/Auth/UserInviteController.php @@ -0,0 +1,106 @@ +inviteService = $inviteService; + $this->userRepo = $userRepo; + $this->middleware('guest'); + parent::__construct(); + } + + /** + * Show the page for the user to set the password for their account. + * @param string $token + * @return Factory|View|RedirectResponse + * @throws Exception + */ + public function showSetPassword(string $token) + { + try { + $this->inviteService->checkTokenAndGetUserId($token); + } catch (Exception $exception) { + return $this->handleTokenException($exception); + } + + return view('auth.invite-set-password', [ + 'token' => $token, + ]); + } + + /** + * Sets the password for an invited user and then grants them access. + * @param string $token + * @param Request $request + * @return RedirectResponse|Redirector + * @throws Exception + */ + public function setPassword(string $token, Request $request) + { + $this->validate($request, [ + 'password' => 'required|min:6' + ]); + + try { + $userId = $this->inviteService->checkTokenAndGetUserId($token); + } catch (Exception $exception) { + return $this->handleTokenException($exception); + } + + $user = $this->userRepo->getById($userId); + $user->password = bcrypt($request->get('password')); + $user->email_confirmed = true; + $user->save(); + + auth()->login($user); + session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')])); + $this->inviteService->deleteByUser($user); + + return redirect('/'); + } + + /** + * Check and validate the exception thrown when checking an invite token. + * @param Exception $exception + * @return RedirectResponse|Redirector + * @throws Exception + */ + protected function handleTokenException(Exception $exception) + { + if ($exception instanceof UserTokenNotFoundException) { + return redirect('/'); + } + + if ($exception instanceof UserTokenExpiredException) { + session()->flash('error', trans('errors.invite_token_expired')); + return redirect('/password/email'); + } + + throw $exception; + } + +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 570896ab6..c9d2560ba 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -1,6 +1,7 @@ user = $user; $this->userRepo = $userRepo; + $this->inviteService = $inviteService; $this->imageRepo = $imageRepo; parent::__construct(); } @@ -75,8 +79,10 @@ class UserController extends Controller ]; $authMethod = config('auth.method'); - if ($authMethod === 'standard') { - $validationRules['password'] = 'required|min:5'; + $sendInvite = ($request->get('send_invite', 'false') === 'true'); + + if ($authMethod === 'standard' && !$sendInvite) { + $validationRules['password'] = 'required|min:6'; $validationRules['password-confirm'] = 'required|same:password'; } elseif ($authMethod === 'ldap') { $validationRules['external_auth_id'] = 'required'; @@ -86,13 +92,17 @@ class UserController extends Controller $user = $this->user->fill($request->all()); if ($authMethod === 'standard') { - $user->password = bcrypt($request->get('password')); + $user->password = bcrypt($request->get('password', str_random(32))); } elseif ($authMethod === 'ldap') { $user->external_auth_id = $request->get('external_auth_id'); } $user->save(); + if ($sendInvite) { + $this->inviteService->sendInvitation($user); + } + if ($request->filled('roles')) { $roles = $request->get('roles'); $this->userRepo->setUserRoles($user, $roles); @@ -139,7 +149,7 @@ class UserController extends Controller $this->validate($request, [ 'name' => 'min:2', 'email' => 'min:2|email|unique:users,email,' . $id, - 'password' => 'min:5|required_with:password_confirm', + 'password' => 'min:6|required_with:password_confirm', 'password-confirm' => 'same:password|required_with:password', 'setting' => 'array', 'profile_image' => $this->imageRepo->getImageValidationRules(), diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js index 8c12da9b4..14cf08ae2 100644 --- a/resources/assets/js/components/index.js +++ b/resources/assets/js/components/index.js @@ -28,6 +28,7 @@ import bookSort from "./book-sort"; import settingAppColorPicker from "./setting-app-color-picker"; import entityPermissionsEditor from "./entity-permissions-editor"; import templateManager from "./template-manager"; +import newUserPassword from "./new-user-password"; const componentMapping = { 'dropdown': dropdown, @@ -60,6 +61,7 @@ const componentMapping = { 'setting-app-color-picker': settingAppColorPicker, 'entity-permissions-editor': entityPermissionsEditor, 'template-manager': templateManager, + 'new-user-password': newUserPassword, }; window.components = {}; diff --git a/resources/assets/js/components/new-user-password.js b/resources/assets/js/components/new-user-password.js new file mode 100644 index 000000000..9c4c21c14 --- /dev/null +++ b/resources/assets/js/components/new-user-password.js @@ -0,0 +1,28 @@ + +class NewUserPassword { + + constructor(elem) { + this.elem = elem; + this.inviteOption = elem.querySelector('input[name=send_invite]'); + + if (this.inviteOption) { + this.inviteOption.addEventListener('change', this.inviteOptionChange.bind(this)); + this.inviteOptionChange(); + } + } + + inviteOptionChange() { + const inviting = (this.inviteOption.value === 'true'); + const passwordBoxes = this.elem.querySelectorAll('input[type=password]'); + for (const input of passwordBoxes) { + input.disabled = inviting; + } + const container = this.elem.querySelector('#password-input-container'); + if (container) { + container.style.display = inviting ? 'none' : 'block'; + } + } + +} + +export default NewUserPassword; \ No newline at end of file diff --git a/resources/assets/js/components/toggle-switch.js b/resources/assets/js/components/toggle-switch.js index 3dd1ce85c..b9b96afc5 100644 --- a/resources/assets/js/components/toggle-switch.js +++ b/resources/assets/js/components/toggle-switch.js @@ -11,6 +11,11 @@ class ToggleSwitch { stateChange() { this.input.value = (this.checkbox.checked ? 'true' : 'false'); + + // Dispatch change event from hidden input so they can be listened to + // like a normal checkbox. + const changeEvent = new Event('change'); + this.input.dispatchEvent(changeEvent); } } diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php index 4474fb7ee..37346097f 100644 --- a/resources/lang/en/auth.php +++ b/resources/lang/en/auth.php @@ -67,7 +67,11 @@ return [ // User Invite 'user_invite_email_subject' => 'You have been invited to join :appName!', - 'user_invite_email_greeting' => 'A user account has been created for you on :appName.', + 'user_invite_email_greeting' => 'An account has been created for you on :appName.', 'user_invite_email_text' => 'Click the button below to set an account password and gain access:', 'user_invite_email_action' => 'Set Account Password', + 'user_invite_page_welcome' => 'Welcome to :appName!', + 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', + 'user_invite_page_confirm_button' => 'Confirm Password', + 'user_invite_success' => 'Password set, you now have access to :appName!' ]; \ No newline at end of file diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index d66dcc92d..c3b47744d 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -27,7 +27,7 @@ return [ 'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.', 'social_driver_not_found' => 'Social driver not found', 'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.', - 'invite_token_expired' => 'This invitation link has expired. You can try to reset your account password or request a new invite from an administrator.', + 'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.', // System 'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 78f86b68e..bb542a588 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -109,7 +109,9 @@ return [ 'users_role' => 'User Roles', 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', 'users_password' => 'User Password', - 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 5 characters long.', + 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 6 characters long.', + 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', + 'users_send_invite_option' => 'Send user invite email', 'users_external_auth_id' => 'External Authentication ID', 'users_external_auth_id_desc' => 'This is the ID used to match this user when communicating with your LDAP system.', 'users_password_warning' => 'Only fill the below if you would like to change your password.', diff --git a/resources/views/auth/invite-set-password.blade.php b/resources/views/auth/invite-set-password.blade.php new file mode 100644 index 000000000..807bd417f --- /dev/null +++ b/resources/views/auth/invite-set-password.blade.php @@ -0,0 +1,27 @@ +@extends('simple-layout') + +@section('content') + +
+
+

{{ trans('auth.user_invite_page_welcome', ['appName' => setting('app-name')]) }}

+

{{ trans('auth.user_invite_page_text', ['appName' => setting('app-name')]) }}

+ +
+ {!! csrf_field() !!} + +
+ + @include('form.password', ['name' => 'password', 'placeholder' => trans('auth.password_hint')]) +
+ +
+ +
+ +
+ +
+
+ +@stop diff --git a/resources/views/users/form.blade.php b/resources/views/users/form.blade.php index 3d073b2c8..32b717ec8 100644 --- a/resources/views/users/form.blade.php +++ b/resources/views/users/form.blade.php @@ -48,23 +48,40 @@ @endif @if($authMethod === 'standard') -
+
-

{{ trans('settings.users_password_desc') }}

- @if(isset($model)) + + @if(!isset($model))

- {{ trans('settings.users_password_warning') }} + {{ trans('settings.users_send_invite_text') }}

+ + @include('components.toggle-switch', [ + 'name' => 'send_invite', + 'value' => old('send_invite', 'true') === 'true', + 'label' => trans('settings.users_send_invite_option') + ]) + @endif -
-
- - @include('form.password', ['name' => 'password']) -
-
- - @include('form.password', ['name' => 'password-confirm']) + + +
@endif \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 2530a3cb6..d9fdc7455 100644 --- a/routes/web.php +++ b/routes/web.php @@ -217,6 +217,10 @@ Route::post('/register/confirm/resend', 'Auth\ConfirmEmailController@resend'); Route::get('/register/confirm/{token}', 'Auth\ConfirmEmailController@confirm'); Route::post('/register', 'Auth\RegisterController@postRegister'); +// User invitation routes +Route::get('/register/invite/{token}', 'Auth\UserInviteController@showSetPassword'); +Route::post('/register/invite/{token}', 'Auth\UserInviteController@setPassword'); + // Password reset link request routes... Route::get('/password/email', 'Auth\ForgotPasswordController@showLinkRequestForm'); Route::post('/password/email', 'Auth\ForgotPasswordController@sendResetLinkEmail');